Răsfoiți Sursa

Merge pull request #5811 from weseek/feat/94131-search-by-specific-action

feat: Search by specific action
Shun Miyazawa 3 ani în urmă
părinte
comite
2d4e8d42b1

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

@@ -0,0 +1,74 @@
+import React, { FC, useCallback } from 'react';
+
+import { SupportedActionType } from '~/interfaces/activity';
+
+type Props = {
+  dropdownItems: Array<{actionCategory: string, actionNames: SupportedActionType[]}>
+  actionMap: Map<SupportedActionType, boolean>
+  onSelectAction: (action: SupportedActionType) => void
+  onSelectMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const {
+    dropdownItems, actionMap, onSelectAction, onSelectMultipleAction,
+  } = props;
+
+  const selectActionCheckboxChangedHandler = useCallback((action) => {
+    if (onSelectAction != null) {
+      onSelectAction(action);
+    }
+  }, [onSelectAction]);
+
+  const selectMultipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onSelectMultipleAction != null) {
+      onSelectMultipleAction(actions, isChecked);
+    }
+  }, [onSelectMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 mb-3">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />Action
+      </button>
+      <ul className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { selectMultipleActionCheckboxChangedHandler(item.actionNames, e.target.checked) }}
+                />
+                <label className="form-check-label">{item.actionCategory}</label>
+              </div>
+            </div>
+            {
+              item.actionNames.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { selectActionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {action}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 44 - 4
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -2,34 +2,74 @@ import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
+import {
+  SupportedActionType, AllSupportedActionType, PageActions, CommentActions,
+} from '~/interfaces/activity';
 import { useSWRxActivityList } from '~/stores/activity';
 
 import PaginationWrapper from '../PaginationWrapper';
 
 import { ActivityTable } from './AuditLog/ActivityTable';
-
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
 
 const PAGING_LIMIT = 10;
 
 export const AuditLogManagement: FC = () => {
   const { t } = useTranslation();
 
+  /*
+   * State
+   */
   const [activePage, setActivePage] = useState<number>(1);
   const offset = (activePage - 1) * PAGING_LIMIT;
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(AllSupportedActionType.map(action => [action, true])),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { action: selectedActionList };
 
-  const { data: activityListData, error } = useSWRxActivityList(PAGING_LIMIT, offset);
+  const { data: activityListData, error } = useSWRxActivityList(PAGING_LIMIT, offset, searchFilter);
   const activityList = activityListData?.docs != null ? activityListData.docs : [];
   const totalActivityNum = activityListData?.totalDocs != null ? activityListData.totalDocs : 0;
   const isLoading = activityListData === undefined && error == null;
 
-  const setActivePageBySelectedPageNum = useCallback((selectedPageNum: number) => {
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
     setActivePage(selectedPageNum);
   }, []);
 
+  const selectActionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const selectMultipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
   return (
     <div data-testid="admin-auditlog">
       <h2>{t('AuditLog')}</h2>
 
+      <SelectActionDropdown
+        dropdownItems={[
+          { actionCategory: 'Page', actionNames: PageActions },
+          { actionCategory: 'Comment', actionNames: CommentActions },
+        ]}
+        actionMap={actionMap}
+        onSelectAction={selectActionCheckboxChangedHandler}
+        onSelectMultipleAction={selectMultipleActionCheckboxChangedHandler}
+      />
+
       { isLoading
         ? (
           <div className="text-muted text-center mb-5">
@@ -41,7 +81,7 @@ export const AuditLogManagement: FC = () => {
             <ActivityTable activityList={activityList} />
             <PaginationWrapper
               activePage={activePage}
-              changePage={setActivePageBySelectedPageNum}
+              changePage={setActivePageHandler}
               totalItemsCount={totalActivityNum}
               pagingLimit={PAGING_LIMIT}
               align="center"

+ 27 - 4
packages/app/src/interfaces/activity.ts

@@ -21,6 +21,7 @@ const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 
 export const SUPPORTED_TARGET_MODEL_TYPE = {
   MODEL_PAGE,
+  MODEL_COMMENT,
 } as const;
 
 export const SUPPORTED_EVENT_MODEL_TYPE = {
@@ -46,15 +47,37 @@ export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_
 export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
 export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
 
-type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_TYPE[keyof typeof SUPPORTED_TARGET_MODEL_TYPE];
+
+/*
+ * For AuditLogManagement.tsx
+ */
+export const PageActions = Object.values({
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+} as const);
+
+export const CommentActions = Object.values({
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+} as const);
+
+
+export type SupportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_TYPE[keyof typeof SUPPORTED_TARGET_MODEL_TYPE];
 // type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_TYPE[keyof typeof SUPPORTED_EVENT_MODEL_TYPE];
-type supportedActionType = typeof SUPPORTED_ACTION_TYPE[keyof typeof SUPPORTED_ACTION_TYPE];
+export type SupportedActionType = typeof SUPPORTED_ACTION_TYPE[keyof typeof SUPPORTED_ACTION_TYPE];
 
 export type IActivity = {
   user?: IUser
-  targetModel: supportedTargetModelType
+  targetModel: SupportedTargetModelType
   targe: string
-  action: supportedActionType
+  action: SupportedActionType
   createdAt: Date
 }
 

+ 2 - 2
packages/app/src/server/models/activity.ts

@@ -104,9 +104,9 @@ activitySchema.post('save', async(savedActivity: ActivityDocument) => {
   activityEvent.emit('create', targetUsers, savedActivity);
 });
 
-activitySchema.statics.getPaginatedActivity = async function(limit: number, offset: number) {
+activitySchema.statics.getPaginatedActivity = async function(limit: number, offset: number, query) {
   const paginateResult = await this.paginate(
-    {},
+    query,
     {
       limit,
       offset,

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

@@ -2,7 +2,7 @@ import express, { Request, Router } from 'express';
 import rateLimit from 'express-rate-limit';
 import { query } from 'express-validator';
 
-import { IActivity } from '~/interfaces/activity';
+import { IActivity, AllSupportedActionType } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -20,12 +20,13 @@ const validator = {
   list: [
     query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
     query('offset').optional().isInt().withMessage('page must be a number'),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
   ],
 };
 
 const apiLimiter = rateLimit({
   windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 10, // limit each IP to 10 requests per windowMs
+  max: 30, // limit each IP to 30 requests per windowMs
   message:
     'Too many requests sent from this IP, please try again after 15 minutes.',
 });
@@ -43,7 +44,13 @@ module.exports = (crowi: Crowi): Router => {
     const offset = req.query.offset || 1;
 
     try {
-      const paginationResult = await Activity.getPaginatedActivity(limit, offset);
+      const parsedSearchFilter = JSON.parse(req.query.searchFilter as string || '');
+      const canContainActionFilterToQuery = parsedSearchFilter.action.every(a => AllSupportedActionType.includes(a));
+      const query = {
+        action: canContainActionFilterToQuery ? parsedSearchFilter.action : [],
+      };
+
+      const paginationResult = await Activity.getPaginatedActivity(limit, offset, query);
 
       const User = crowi.model('User');
       const serializedDocs = paginationResult.docs.map((doc: IActivity) => {

+ 11 - 4
packages/app/src/stores/activity.ts

@@ -2,12 +2,19 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '../client/util/apiv3-client';
-import { IActivityHasId } from '../interfaces/activity';
+import { IActivityHasId, SupportedActionType } from '../interfaces/activity';
 import { PaginateResult } from '../interfaces/mongoose-utils';
 
-export const useSWRxActivityList = (limit?: number, offset?: number): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+
+type ISearchFilter = {
+  action?: SupportedActionType[]
+}
+
+export const useSWRxActivityList = (limit?: number, offset?: number, searchFilter?: ISearchFilter): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+  const stringifiedSearchFilter = JSON.stringify(searchFilter);
   return useSWRImmutable(
-    ['/activity', limit, offset],
-    (endpoint, limit, offset) => apiv3Get(endpoint, { limit, offset }).then(result => result.data.serializedPaginationResult),
+    ['/activity', limit, offset, stringifiedSearchFilter],
+    (endpoint, limit, offset, stringifiedSearchFilter) => apiv3Get(endpoint, { limit, offset, searchFilter: stringifiedSearchFilter })
+      .then(result => result.data.serializedPaginationResult),
   );
 };