jam411 3 lat temu
rodzic
commit
3aa8ca94ab

+ 1 - 1
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -47,7 +47,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>
                   { activity.user != null && (
                     <>
-                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <UserPicture user={activity.user} />
                       <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
                     </>
                   )}

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -44,7 +44,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           return (
             <tr key={relation._id}>
               <td>
-                <UserPicture user={relatedUser} className="picture rounded-circle" />
+                <UserPicture user={relatedUser} />
               </td>
               <td>
                 <strong>{relatedUser.username}</strong>

+ 231 - 0
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -0,0 +1,231 @@
+import React, { Fragment } from 'react';
+
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import SortIcons from './SortIcons';
+import UserMenu from './UserMenu';
+
+
+class UserTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
+  }
+
+  /**
+   * return status label element by `userStatus`
+   * @param {string} userStatus
+   * @return status label element
+   */
+  getUserStatusLabel(userStatus) {
+    let additionalClassName;
+    let text;
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'badge-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'badge-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'badge-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'badge-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'badge-pink';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`badge badge-pill ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  }
+
+  /**
+   * return admin label element by `isAdmin`
+   * @param {string} isAdmin
+   * @return admin label element
+   */
+  getUserAdminLabel(isAdmin) {
+    const { t } = this.props;
+
+    if (isAdmin) {
+      return <span className="badge badge-indigo badge-pill ml-2">{t('admin:user_management.user_table.administrator')}</span>;
+    }
+  }
+
+  sortIconsClickedHandler(sort, sortOrder) {
+    const isAsc = sortOrder === 'asc';
+
+    const { adminUsersContainer } = this.props;
+    adminUsersContainer.sort(sort, isAsc);
+  }
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+
+    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
+    return (
+      <Fragment>
+        <div className="table-responsive text-nowrap h-100">
+          <table className="table table-default table-bordered table-user-list">
+            <thead>
+              <tr>
+                <th width="100px">#</th>
+                <th>
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      {t('user_management.status')}
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'status'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('status', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th>
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      <code>username</code>
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'username'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('username', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th>
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      {t('Name')}
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'name'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('name', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th>
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      {t('Email')}
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'email'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('email', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th width="100px">
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      {t('Created')}
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('createdAt', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th width="150px">
+                  <div className="d-flex align-items-center">
+                    <div className="mr-3">
+                      {t('last_login')}
+                    </div>
+                    <SortIcons
+                      isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                      isAsc={isCurrentSortOrderAsc}
+                      onClick={(sortOrder) => {
+                        this.sortIconsClickedHandler('lastLoginAt', sortOrder);
+                      }}
+                    />
+                  </div>
+                </th>
+                <th width="70px"></th>
+              </tr>
+            </thead>
+            <tbody>
+              {adminUsersContainer.state.users.map((user) => {
+                return (
+                  <tr data-testid="user-table-tr" key={user._id}>
+                    <td>
+                      <UserPicture user={user} />
+                    </td>
+                    <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
+                    <td>
+                      <strong>{user.username}</strong>
+                    </td>
+                    <td>{user.name}</td>
+                    <td>{user.email}</td>
+                    <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
+                    <td>
+                      {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
+                    </td>
+                    <td>
+                      <UserMenu user={user} />
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+
+UserTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+};
+
+const UserTableWrapperFC = (props) => {
+  const { t } = useTranslation('admin');
+  return <UserTable t={t} {...props} />;
+};
+
+const UserTableWrapper = withUnstatedContainers(UserTableWrapperFC, [AdminUsersContainer]);
+
+export default UserTableWrapper;

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.tsx

@@ -148,7 +148,7 @@ const UserTable = (props: UserTableProps) => {
             return (
               <tr data-testid="user-table-tr" key={user._id}>
                 <td>
-                  <UserPicture user={user} className="picture rounded-circle" />
+                  <UserPicture user={user} />
                 </td>
                 <td>
                   {getUserStatusLabel(user.status)}

+ 3 - 3
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -39,7 +39,7 @@ export const TrashPageAlert = (): JSX.Element => {
   }
 
 
-  const lastUpdateUserName = pageData?.lastUpdateUser?.name;
+  const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
 
@@ -98,9 +98,9 @@ export const TrashPageAlert = (): JSX.Element => {
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           <br />
-          <UserPicture user={{ username: lastUpdateUserName }} />
+          <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { lastUpdateUserName } at {deletedAt || pageData?.updatedAt}
+            Deleted by { deleteUser?.name } at {deletedAt || pageData?.updatedAt}
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">

+ 5 - 0
packages/app/src/styles/bootstrap/_override.scss

@@ -167,3 +167,8 @@ fieldset[disabled] .btn {
   word-break: break-word;
   overflow-wrap: break-word;
 }
+
+// prevent tooltip flickering (flashing) on hover
+.tooltip {
+  pointer-events: none;
+}

+ 0 - 104
packages/ui/src/components/User/UserPicture.jsx

@@ -1,104 +0,0 @@
-import React from 'react';
-
-import { pagePathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-
-const { userPageRoot } = pagePathUtils;
-
-
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
-export class UserPicture extends React.Component {
-
-  getClassName() {
-    const className = ['rounded-circle', 'picture'];
-    // size
-    if (this.props.size) {
-      className.push(`picture-${this.props.size}`);
-    }
-
-    return className.join(' ');
-  }
-
-  renderForNull() {
-    return (
-      <img
-        src={DEFAULT_IMAGE}
-        alt="someone"
-        className={this.getClassName()}
-      />
-    );
-  }
-
-  RootElmWithoutLink = (props) => {
-    return <span {...props}>{props.children}</span>;
-  };
-
-  RootElmWithLink = (props) => {
-    const { user } = this.props;
-    const href = userPageRoot(user);
-    // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
-    // Nested anchor tags causes a warning.
-    // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
-    return <span onClick={() => { window.location.href = href }} {...props}>{props.children}</span>;
-  };
-
-  withTooltip = (RootElm) => {
-    const { user } = this.props;
-    const id = `user-picture-${Math.random().toString(32).substring(2)}`;
-
-    return props => (
-      <>
-        <RootElm id={id}>{props.children}</RootElm>
-        <UncontrolledTooltip placement="bottom" target={id} delay={0} fade={false}>
-          @{user.username}<br />
-          {user.name}
-        </UncontrolledTooltip>
-      </>
-    );
-  };
-
-  render() {
-    const user = this.props.user;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
-
-    const { noLink, noTooltip } = this.props;
-
-    // determine RootElm
-    let RootElm = noLink ? this.RootElmWithoutLink : this.RootElmWithLink;
-    if (!noTooltip) {
-      RootElm = this.withTooltip(RootElm);
-    }
-
-    const userPictureSrc = user.imageUrlCached || DEFAULT_IMAGE;
-
-    return (
-      <RootElm>
-        <img
-          src={userPictureSrc}
-          alt={user.username}
-          className={this.getClassName()}
-        />
-      </RootElm>
-    );
-  }
-
-}
-
-UserPicture.propTypes = {
-  user: PropTypes.object,
-  size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  noLink: PropTypes.bool,
-  noTooltip: PropTypes.bool,
-};
-
-UserPicture.defaultProps = {
-  size: null,
-  noLink: false,
-  noTooltip: false,
-};

+ 120 - 0
packages/ui/src/components/User/UserPicture.tsx

@@ -0,0 +1,120 @@
+import React, {
+  forwardRef, useCallback, useRef,
+} from 'react';
+
+import type { Ref, IUser } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import type { UncontrolledTooltipProps } from 'reactstrap';
+
+const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
+
+const { userPageRoot } = pagePathUtils;
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+type UserPictureRootProps = {
+  user: Partial<IUser>,
+  className?: string,
+  children?: React.ReactNode,
+}
+
+const UserPictureRootWithoutLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  return <span ref={ref} className={props.className}>{props.children}</span>;
+});
+
+const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  const router = useRouter();
+
+  const { user } = props;
+  const href = userPageRoot(user);
+
+  const clickHandler = useCallback(() => {
+    router.push(href);
+  }, [href, router]);
+
+  // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
+  // Nested anchor tags causes a warning.
+  // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
+  return <span ref={ref} className={props.className} onClick={clickHandler} style={{ cursor: 'pointer' }}>{props.children}</span>;
+});
+
+
+// wrapper with Tooltip
+const withTooltip = (UserPictureSpanElm: React.ForwardRefExoticComponent<UserPictureRootProps & React.RefAttributes<HTMLSpanElement>>) => {
+  return (props: UserPictureRootProps) => {
+    const { user } = props;
+
+    const userPictureRef = useRef<HTMLSpanElement>(null);
+
+    return (
+      <>
+        <UserPictureSpanElm ref={userPictureRef} user={user}>{props.children}</UserPictureSpanElm>
+        <UncontrolledTooltip placement="bottom" target={userPictureRef} delay={0} fade={false}>
+          @{user.username}<br />
+          {user.name}
+        </UncontrolledTooltip>
+      </>
+    );
+  };
+};
+
+
+/**
+ * type guard to determine whether the specified object is IUser
+ */
+const isUserObj = (obj: Partial<IUser> | Ref<IUser>): obj is Partial<IUser> => {
+  return typeof obj !== 'string' && 'username' in obj;
+};
+
+
+type Props = {
+  user?: Partial<IUser> | Ref<IUser> | null,
+  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
+  noLink?: boolean,
+  noTooltip?: boolean,
+};
+
+export const UserPicture = React.memo((props: Props): JSX.Element => {
+
+  const {
+    user, size, noLink, noTooltip,
+  } = props;
+
+  const classNames = ['rounded-circle', 'picture'];
+  if (size != null) {
+    classNames.push(`picture-${size}`);
+  }
+  const className = classNames.join(' ');
+
+  if (user == null || !isUserObj(user)) {
+    return (
+      <img
+        src={DEFAULT_IMAGE}
+        alt="someone"
+        className={className}
+      />
+    );
+  }
+
+  // determine RootElm
+  const UserPictureSpanElm = noLink ? UserPictureRootWithoutLink : UserPictureRootWithLink;
+  const UserPictureRootElm = noTooltip
+    ? UserPictureSpanElm
+    : withTooltip(UserPictureSpanElm);
+
+  const userPictureSrc = user.imageUrlCached ?? DEFAULT_IMAGE;
+
+  return (
+    <UserPictureRootElm user={user}>
+      <img
+        src={userPictureSrc}
+        alt={user.username}
+        className={className}
+      />
+    </UserPictureRootElm>
+  );
+});
+UserPicture.displayName = 'UserPicture';