Преглед изворни кода

args[0]とargs[2]が存在しないときの検証と救済処理を作成しました

keigo-h пре 3 година
родитељ
комит
cfe0cc48ea

+ 17 - 0
packages/app/src/components/Common/CountBadge.tsx

@@ -0,0 +1,17 @@
+import React, { FC } from 'react';
+
+type CountProps = {
+  count: number
+}
+
+const CountBadge: FC<CountProps> = (props:CountProps) => {
+  return (
+    <>
+      <span className="grw-count-badge px-2 badge badge-pill badge-light">
+        {props.count}
+      </span>
+    </>
+  );
+};
+
+export default CountBadge;

+ 6 - 1
packages/app/src/components/DescendantsPageList.tsx

@@ -62,7 +62,12 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   }
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    toastSuccess(args[2] ? t('deleted_pages_completely', { path: args[0] }) : t('deleted_pages', { path: args[0] }));
+    if (args[0] != null && args[2] != null) {
+      toastSuccess(args[2] ? t('deleted_pages_completely', { path: args[0] }) : t('deleted_pages', { path: args[0] }));
+    }
+    else {
+      return;
+    }
 
     advancePt();
 

+ 73 - 0
packages/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -0,0 +1,73 @@
+import React, {
+  Ref, useEffect, useState,
+} from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+type Props<T> = {
+  swrInifiniteResponse : SWRInfiniteResponse<T>
+  children: React.ReactChild | ((item: T) => React.ReactNode),
+  loadingIndicator?: React.ReactNode
+  endingIndicator?: React.ReactNode
+  isReachingEnd?: boolean,
+  offset?: number
+}
+
+const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
+  const [intersecting, setIntersecting] = useState<boolean>(false);
+  const [element, setElement] = useState<HTMLElement>();
+  useEffect(() => {
+    if (element != null) {
+      const observer = new IntersectionObserver((entries) => {
+        setIntersecting(entries[0]?.isIntersecting);
+      });
+      observer.observe(element);
+      return () => observer.unobserve(element);
+    }
+    return;
+  }, [element]);
+  return [intersecting, el => el && setElement(el)];
+};
+
+const LoadingIndicator = (): React.ReactElement => {
+  return (
+    <div className="text-muted text-center">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+    </div>
+  );
+};
+
+const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+  const {
+    swrInifiniteResponse: {
+      setSize, data, isValidating,
+    },
+    children,
+    loadingIndicator,
+    endingIndicator,
+    isReachingEnd,
+    offset = 0,
+  } = props;
+
+  const [intersecting, ref] = useIntersection<HTMLDivElement>();
+
+  useEffect(() => {
+    if (intersecting && !isValidating && !isReachingEnd) {
+      setSize(size => size + 1);
+    }
+  }, [setSize, intersecting]);
+
+  return (
+    <>
+      {typeof children === 'function' ? data?.map(item => children(item)) : children}
+      <div style={{ position: 'relative' }}>
+        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
+        {isReachingEnd
+          ? endingIndicator
+          : loadingIndicator || <LoadingIndicator />
+        }
+      </div>
+    </>
+  );
+};
+
+export default InfiniteScroll;

+ 76 - 0
packages/app/src/components/TagList.tsx

@@ -0,0 +1,76 @@
+import React, {
+  FC, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+
+type TagListProps = {
+  tagData: ITagCountHasId[],
+  totalTags: number,
+  activePage: number,
+  onChangePage?: (selectedPageNumber: number) => void,
+  pagingLimit: number,
+  isPaginationShown?: boolean,
+}
+
+const defaultProps = {
+  isPaginationShown: true,
+};
+
+const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) => {
+  const {
+    tagData, totalTags, activePage, onChangePage, pagingLimit, isPaginationShown,
+  } = props;
+  const isTagExist: boolean = tagData.length > 0;
+  const { t } = useTranslation('');
+
+  const generateTagList = useCallback((tagData) => {
+    return tagData.map((tag:ITagCountHasId, index:number) => {
+      const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
+
+      return (
+        <a
+          key={tag._id}
+          href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
+          className={tagListClasses}
+        >
+          <div className="text-truncate">{tag.name}</div>
+          <div className="ml-4 my-auto py-1 px-2 list-tag-count badge badge-secondary text-white">{tag.count}</div>
+        </a>
+      );
+    });
+  }, []);
+
+  if (!isTagExist) {
+    return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+  }
+
+  return (
+    <>
+      <ul className="list-group text-left mb-4">
+        {generateTagList(tagData)}
+      </ul>
+      {isPaginationShown
+      && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={onChangePage}
+          totalItemsCount={totalTags}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="md"
+        />
+      )
+      }
+    </>
+  );
+
+};
+
+TagList.defaultProps = defaultProps;
+
+export default TagList;

+ 59 - 0
packages/app/src/components/TagPage.tsx

@@ -0,0 +1,59 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from './TagCloudBox';
+import TagList from './TagList';
+
+const PAGING_LIMIT = 10;
+
+const TagPage: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
+  const { t } = useTranslation('');
+
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  // todo: adjust margin and redesign tags page
+  return (
+    <div className="grw-container-convertible mb-5 pb-5">
+      <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
+      <div className="px-3 mb-5 text-center">
+        <TagCloudBox tags={tagData} minSize={20} />
+      </div>
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <div data-testid="grw-tags-list">
+            <TagList
+              tagData={tagData}
+              totalTags={totalCount}
+              activePage={activePage}
+              onChangePage={setOffsetByPageNumber}
+              pagingLimit={PAGING_LIMIT}
+            />
+          </div>
+        )
+      }
+    </div>
+  );
+
+};
+
+export default TagPage;

+ 48 - 0
packages/app/src/interfaces/activity.ts

@@ -0,0 +1,48 @@
+// Model
+const MODEL_PAGE = 'Page';
+const MODEL_COMMENT = 'Comment';
+
+// Action
+const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_CREATE = 'PAGE_CREATE';
+const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
+const ACTION_PAGE_RENAME = 'PAGE_RENAME';
+const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
+const ACTION_PAGE_DELETE = 'PAGE_DELETE';
+const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
+const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
+const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+
+
+export const SUPPORTED_TARGET_MODEL_TYPE = {
+  MODEL_PAGE,
+} as const;
+
+export const SUPPORTED_EVENT_MODEL_TYPE = {
+  MODEL_COMMENT,
+} as const;
+
+export const SUPPORTED_ACTION_TYPE = {
+  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,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+} as const;
+
+
+export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
+export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
+export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
+
+// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
+// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_NAMES[keyof typeof SUPPORTED_EVENT_MODEL_NAMES];
+// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];

+ 12 - 0
packages/app/src/server/models/errors.ts

@@ -0,0 +1,12 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class PathAlreadyExistsError extends ExtensibleCustomError {
+
+  targetPath: string;
+
+  constructor(message: string, targetPath: string) {
+    super(message);
+    this.targetPath = targetPath;
+  }
+
+}

+ 28 - 0
packages/app/src/server/models/vo/error-search.ts

@@ -0,0 +1,28 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { AllTermsKey } from '~/server/interfaces/search';
+
+export class SearchError extends ExtensibleCustomError {
+
+  readonly id = 'SearchError'
+
+  unavailableTermsKeys!: AllTermsKey[]
+
+  constructor(message = '', unavailableTermsKeys: AllTermsKey[]) {
+    super(message);
+    this.unavailableTermsKeys = unavailableTermsKeys;
+  }
+
+}
+
+export const isSearchError = (err: any): err is SearchError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof SearchError) {
+    return true;
+  }
+
+  return err?.id === 'SearchError';
+};

+ 176 - 0
packages/app/src/server/routes/search.ts

@@ -0,0 +1,176 @@
+import loggerFactory from '~/utils/logger';
+import { isSearchError } from '../models/vo/error-search';
+
+const logger = loggerFactory('growi:routes:search');
+
+/**
+ * @swagger
+ *
+ *   components:
+ *     schemas:
+ *       ElasticsearchResult:
+ *         description: Elasticsearch result v1
+ *         type: object
+ *         properties:
+ *           meta:
+ *             type: object
+ *             properties:
+ *               took:
+ *                 type: number
+ *                 description: Time Elasticsearch took to execute a search(milliseconds)
+ *                 example: 34
+ *               total:
+ *                 type: number
+ *                 description: Number of documents matching search criteria
+ *                 example: 2
+ *               results:
+ *                 type: number
+ *                 description: Actual array length of search results
+ *                 example: 2
+ *
+ */
+module.exports = function(crowi, app) {
+  // var debug = require('debug')('growi:routes:search')
+  const ApiResponse = require('../util/apiResponse');
+  const ApiPaginate = require('../util/apiPaginate');
+
+  const actions: any = {};
+  const api: any = {};
+
+  actions.searchPage = function(req, res) {
+    const keyword = req.query.q || null;
+
+    return res.render('search', {
+      q: keyword,
+    });
+  };
+
+  /**
+   * @swagger
+   *
+   *   /search:
+   *     get:
+   *       tags: [Search, CrowiCompatibles]
+   *       operationId: searchPages
+   *       summary: /search
+   *       description: Search pages
+   *       parameters:
+   *         - in: query
+   *           name: q
+   *           schema:
+   *             type: string
+   *             description: keyword
+   *             example: daily report
+   *           required: true
+   *         - in: query
+   *           name: path
+   *           schema:
+   *             $ref: '#/components/schemas/Page/properties/path'
+   *         - in: query
+   *           name: offset
+   *           schema:
+   *             $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
+   *         - in: query
+   *           name: limit
+   *           schema:
+   *             $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
+   *       responses:
+   *         200:
+   *           description: Succeeded to get list of pages.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   ok:
+   *                     $ref: '#/components/schemas/V1Response/properties/ok'
+   *                   meta:
+   *                     $ref: '#/components/schemas/ElasticsearchResult/properties/meta'
+   *                   totalCount:
+   *                     type: integer
+   *                     description: total count of pages
+   *                     example: 35
+   *                   data:
+   *                     type: array
+   *                     items:
+   *                       $ref: '#/components/schemas/Page'
+   *                     description: page list
+   *         403:
+   *           $ref: '#/components/responses/403'
+   *         500:
+   *           $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {get} /search search page
+   * @apiName Search
+   * @apiGroup Search
+   *
+   * @apiParam {String} q keyword
+   * @apiParam {String} path
+   * @apiParam {String} offset
+   * @apiParam {String} limit
+   */
+  api.search = async function(req, res) {
+    const user = req.user;
+    const {
+      q = null, nq = null, type = null, sort = null, order = null,
+    } = req.query;
+    let paginateOpts;
+
+    try {
+      paginateOpts = ApiPaginate.parseOptionsForElasticSearch(req.query);
+    }
+    catch (e) {
+      res.json(ApiResponse.error(e));
+    }
+
+    if (q === null || q === '') {
+      return res.json(ApiResponse.error('The param "q" should not empty.'));
+    }
+
+    const { searchService } = crowi;
+    if (!searchService.isReachable) {
+      return res.json(ApiResponse.error('SearchService is not reachable.'));
+    }
+
+    let userGroups = [];
+    if (user != null) {
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const searchOpts = {
+      ...paginateOpts, type, sort, order,
+    };
+
+    let searchResult;
+    let delegatorName;
+    try {
+      const keyword = decodeURIComponent(q);
+      const nqName = nq ?? decodeURIComponent(nq);
+      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, nqName, user, userGroups, searchOpts);
+    }
+    catch (err) {
+      logger.error('Failed to search', err);
+
+      if (isSearchError(err)) {
+        const { unavailableTermsKeys } = err;
+        return res.json(ApiResponse.error(err, 400, { unavailableTermsKeys }));
+      }
+
+      return res.json(ApiResponse.error(err));
+    }
+
+    let result;
+    try {
+      result = await searchService.formatSearchResult(searchResult, delegatorName, user, userGroups);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err));
+    }
+    return res.json(ApiResponse.success(result));
+  };
+
+  actions.api = api;
+  return actions;
+};