Kaynağa Gözat

Merge pull request #5928 from weseek/feat/95195-incremental-search-for-username

feat: Icremental search for username
Shun Miyazawa 3 yıl önce
ebeveyn
işleme
b1dbc3a78c

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

@@ -50,7 +50,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
   }, []);
 
   return (
-    <div className="btn-group mr-2 mb-3">
+    <div className="btn-group mr-2">
       <DatePicker
         selectsRange
         startDate={startDate}

+ 120 - 0
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -0,0 +1,120 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
+
+import { useSWRxUsernames } from '~/stores/user';
+
+
+const Categories = {
+  activeUser: 'Active User',
+  inactiveUser: 'Inactive User',
+  activitySnapshotUser: 'Activity Snapshot User',
+} as const;
+
+type CategorieType = typeof Categories[keyof typeof Categories]
+
+type UserDataType = {
+  username: string
+  category: CategorieType
+}
+
+type Props = {
+  onChange: (text: string[]) => void
+}
+
+export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+  const { onChange } = props;
+
+  /*
+   * State
+   */
+  const [searchKeyword, setSearchKeyword] = useState<string>('');
+
+  /*
+   * Fetch
+   */
+  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
+  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
+  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
+  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const isLoading = usernameData === undefined && error == null;
+
+  const allUser: UserDataType[] = [];
+  const pushToAllUser = (usernames: string[], category: CategorieType) => {
+    usernames.forEach(username => allUser.push({ username, category }));
+  };
+  pushToAllUser(activeUsernames, Categories.activeUser);
+  pushToAllUser(inactiveUsernames, Categories.inactiveUser);
+  pushToAllUser(activitySnapshotUsernames, Categories.activitySnapshotUser);
+
+  /*
+   * Functions
+   */
+  const changeHandler = useCallback((userData: UserDataType[]) => {
+    if (onChange != null) {
+      const usernames = userData.map(user => user.username);
+      onChange(usernames);
+    }
+  }, [onChange]);
+
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
+  }, []);
+
+  const renderMenu = useCallback((allUser: UserDataType[], menuProps) => {
+    if (allUser == null || allUser.length === 0) {
+      return <></>;
+    }
+
+    let index = 0;
+    const items = Object.values(Categories).map((category) => {
+      const userData = allUser.filter(user => user.category === category);
+      return (
+        <Fragment key={category}>
+          {index !== 0 && <Menu.Divider />}
+          <Menu.Header>{category}</Menu.Header>
+          {userData.map((user) => {
+            const item = (
+              <MenuItem key={index} option={user} position={index}>
+                {user.username}
+              </MenuItem>
+            );
+            index++;
+            return item;
+          })}
+        </Fragment>
+      );
+    });
+
+    return (
+      <Menu {...menuProps}>{items}</Menu>
+    );
+  }, []);
+
+  return (
+    <div className="input-group mr-2">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="icon-people" />
+        </span>
+      </div>
+      <AsyncTypeahead
+        id="auditlog-username-typeahead-asynctypeahead"
+        multiple
+        delay={400}
+        minLength={0}
+        placeholder="username"
+        caseSensitive={false}
+        isLoading={isLoading}
+        options={allUser}
+        onSearch={searchHandler}
+        onChange={changeHandler}
+        renderMenu={renderMenu}
+        labelKey={(option: UserDataType) => `${option.username}`}
+      />
+    </div>
+  );
+};

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

@@ -27,7 +27,7 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
   }, [onChangeMultipleAction]);
 
   return (
-    <div className="btn-group mr-2 mb-3">
+    <div className="btn-group mr-2">
       <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
         <i className="fa fa-fw fa-bolt" />Action
       </button>

+ 29 - 17
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -12,6 +12,7 @@ import PaginationWrapper from '../PaginationWrapper';
 
 import { ActivityTable } from './AuditLog/ActivityTable';
 import { DateRangePicker } from './AuditLog/DateRangePicker';
+import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
 import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
 
 
@@ -19,7 +20,7 @@ const formatDate = (date: Date | null) => {
   if (date == null) {
     return '';
   }
-  return format(new Date(date), 'yyyy/MM/dd');
+  return format(new Date(date), 'yyyy-MM-dd');
 };
 
 const PAGING_LIMIT = 10;
@@ -34,6 +35,7 @@ export const AuditLogManagement: FC = () => {
   const offset = (activePage - 1) * PAGING_LIMIT;
   const [startDate, setStartDate] = useState<Date | null>(null);
   const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
   const [actionMap, setActionMap] = useState(
     new Map<SupportedActionType, boolean>(AllSupportedActionType.map(action => [action, true])),
   );
@@ -43,7 +45,7 @@ export const AuditLogManagement: FC = () => {
    */
   const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
   const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
-  const searchFilter = { action: selectedActionList, date: selectedDate };
+  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
 
   const { data: activityListData, error } = useSWRxActivityList(PAGING_LIMIT, offset, searchFilter);
   const activityList = activityListData?.docs != null ? activityListData.docs : [];
@@ -75,6 +77,10 @@ export const AuditLogManagement: FC = () => {
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
 
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setSelectedUsernames(usernames);
+  }, []);
+
   // eslint-disable-next-line max-len
   const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
 
@@ -82,21 +88,27 @@ export const AuditLogManagement: FC = () => {
     <div data-testid="admin-auditlog">
       <h2 className="admin-setting-header mb-3">{t('AuditLog')}</h2>
 
-      <DateRangePicker
-        startDate={startDate}
-        endDate={endDate}
-        onChangeDatePicker={datePickerChangedHandler}
-      />
-
-      <SelectActionDropdown
-        dropdownItems={[
-          { actionCategory: 'Page', actionNames: PageActions },
-          { actionCategory: 'Comment', actionNames: CommentActions },
-        ]}
-        actionMap={actionMap}
-        onChangeAction={actionCheckboxChangedHandler}
-        onChangeMultipleAction={multipleActionCheckboxChangedHandler}
-      />
+      <div className="form-inline mb-3">
+        <SearchUsernameTypeahead
+          onChange={setUsernamesHandler}
+        />
+
+        <DateRangePicker
+          startDate={startDate}
+          endDate={endDate}
+          onChangeDatePicker={datePickerChangedHandler}
+        />
+
+        <SelectActionDropdown
+          dropdownItems={[
+            { actionCategory: 'Page', actionNames: PageActions },
+            { actionCategory: 'Comment', actionNames: CommentActions },
+          ]}
+          actionMap={actionMap}
+          onChangeAction={actionCheckboxChangedHandler}
+          onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+        />
+      </div>
 
       { isLoading
         ? (

+ 10 - 7
packages/app/src/server/routes/apiv3/activity.ts

@@ -1,4 +1,4 @@
-import { parse, addMinutes, isValid } from 'date-fns';
+import { parseISO, addMinutes, isValid } from 'date-fns';
 import express, { Request, Router } from 'express';
 import rateLimit from 'express-rate-limit';
 import { query } from 'express-validator';
@@ -48,20 +48,23 @@ module.exports = (crowi: Crowi): Router => {
     try {
       const parsedSearchFilter = JSON.parse(req.query.searchFilter as string);
 
+      console.log(parsedSearchFilter);
+
       // add username to query
-      if (typeof parsedSearchFilter.username === 'string') {
-        Object.assign(query, { 'snapshot.username': parsedSearchFilter.username });
+      const canContainUsernameFilterToQuery = parsedSearchFilter.usernames.every(u => typeof u === 'string');
+      if (canContainUsernameFilterToQuery && parsedSearchFilter.usernames.length > 0) {
+        Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
       }
 
       // add action to query
-      const canContainActionFilterToQuery = parsedSearchFilter.action.every(a => AllSupportedActionType.includes(a));
+      const canContainActionFilterToQuery = parsedSearchFilter.actions.every(a => AllSupportedActionType.includes(a));
       if (canContainActionFilterToQuery) {
-        Object.assign(query, { action: parsedSearchFilter.action });
+        Object.assign(query, { action: parsedSearchFilter.actions });
       }
 
       // add date to query
-      const startDate = parse(parsedSearchFilter.date.startDate, 'yyyy/MM/dd', new Date());
-      const endDate = parse(parsedSearchFilter.date.endDate, 'yyyy/MM/dd', new Date());
+      const startDate = parseISO(parsedSearchFilter.dates.startDate);
+      const endDate = parseISO(parsedSearchFilter.dates.endDate);
       if (isValid(startDate) && isValid(endDate)) {
         Object.assign(query, {
           createdAt: {

+ 3 - 2
packages/app/src/stores/activity.ts

@@ -7,8 +7,9 @@ import { PaginateResult } from '../interfaces/mongoose-utils';
 
 
 type ISearchFilter = {
-  date?: {startDate: string | null, endDate: string | null}
-  action?: SupportedActionType[]
+  usernames?: string[]
+  dates?: {startDate: string | null, endDate: string | null}
+  actions?: SupportedActionType[]
 }
 
 export const useSWRxActivityList = (limit?: number, offset?: number, searchFilter?: ISearchFilter): SWRResponse<PaginateResult<IActivityHasId>, Error> => {

+ 1 - 1
packages/app/src/stores/user.tsx

@@ -38,7 +38,7 @@ type usernameResult = {
 
 export const useSWRxUsernames = (q: string, offset?: number, limit?: number, options?: usernameRequestOptions): SWRResponse<usernameResult, Error> => {
   return useSWRImmutable(
-    q != null ? ['/users/usernames', q, offset, limit, options] : null,
+    (q != null && q.trim() !== '') ? ['/users/usernames', q, offset, limit, options] : null,
     (endpoint, q, offset, limit, options) => apiv3Get(endpoint, {
       q, offset, limit, options,
     }).then(result => result.data),