Ver Fonte

Merge pull request #4643 from weseek/feat/api-get-ancestors-children

Feat/api get ancestors children
Yuki Takei há 4 anos atrás
pai
commit
898b52d276

+ 44 - 31
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -5,14 +5,13 @@ import { ItemNode } from './ItemNode';
 import Item from './Item';
 import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
 import { useTargetAndAncestors } from '../../../stores/context';
+import { HasObjectId } from '../../../interfaces/has-object-id';
+
 
 /*
- * Utility to generate initial node and return
+ * Utility to generate initial node
  */
-const generateInitialNode = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
-  const rootPage = targetAndAncestors[targetAndAncestors.length - 1]; // the last item is the root
-  if (rootPage?.path !== '/') throw Error('/ not exist in ancestors');
-
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
   const nodes = targetAndAncestors.map((page): ItemNode => {
     return new ItemNode(page, []);
   });
@@ -26,58 +25,72 @@ const generateInitialNode = (targetAndAncestors: Partial<IPage>[]): ItemNode =>
   return rootNode;
 };
 
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+  const paths = Object.keys(ancestorsChildren);
+
+  let currentNode = rootNode;
+  paths.reverse().forEach((path) => {
+    const childPages = ancestorsChildren[path];
+    currentNode.children = ItemNode.generateNodesFromPages(childPages);
+
+    const nextNode = currentNode.children.filter((node) => {
+      return paths.includes(node.page.path as string);
+    })[0];
+    currentNode = nextNode;
+  });
+
+  return rootNode;
+};
+
+
 /*
  * ItemsTree
  */
 const ItemsTree: FC = () => {
-  // TODO: get from props
-  const path = '/Sandbox/Bootstrap4';
+  // TODO: get from static SWR
+  const path = '/Sandbox/Mathematics';
 
-  // initial request
-  const { data: targetAndAncestors, error } = useTargetAndAncestors();
+  const { data, error } = useTargetAndAncestors();
 
-  // secondary request
-  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(targetAndAncestors != null ? path : null);
+  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(path);
 
   if (error != null || error2 != null) {
     return null;
   }
 
-  if (targetAndAncestors == null) {
+  if (data == null) {
     return null;
   }
 
-  const initialNode = generateInitialNode(targetAndAncestors);
+  const { targetAndAncestors, rootPage } = data;
+
+  let initialNode: ItemNode;
+
+  /*
+   * Before swr response comes back
+   */
+  if (ancestorsChildrenData == null) {
+    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  }
 
   /*
-   * when second SWR resolved
+   * When swr request finishes
    */
-  if (ancestorsChildrenData != null) {
-    // increment initialNode
+  else {
     const { ancestorsChildren } = ancestorsChildrenData;
 
-    // flatten ancestors
-    const partialChildren: ItemNode[] = [];
-    let currentNode = initialNode;
-    while (currentNode.hasChildren() && currentNode?.children?.[0] != null) {
-      const child = currentNode.children[0];
-      partialChildren.push(child);
-      currentNode = child;
-    }
-
-    // update children
-    partialChildren.forEach((node) => {
-      const childPages = ancestorsChildren[node.page.path as string];
-      node.children = ItemNode.generateNodesFromPages(childPages);
-    });
+    const rootNode = new ItemNode(rootPage);
+
+    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
   }
 
   const isOpen = true;
   return (
     <>
-      <Item key={initialNode.page.path} itemNode={initialNode} isOpen={isOpen} />
+      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
     </>
   );
 };
 
+
 export default ItemsTree;

+ 4 - 1
packages/app/src/interfaces/page-listing-results.ts

@@ -7,4 +7,7 @@ export interface AncestorsChildrenResult {
   ancestorsChildren: Record<ParentPath, Partial<IPage & HasObjectId>[]>
 }
 
-export type TargetAndAncestors = Partial<IPage & HasObjectId>[];
+export type TargetAndAncestors = {
+  targetAndAncestors: Partial<IPage & HasObjectId>[]
+  rootPage: Partial<IPage & HasObjectId>,
+}

+ 58 - 31
packages/app/src/server/models/page.ts

@@ -33,12 +33,15 @@ const STATUS_DELETED = 'deleted';
 
 export interface PageDocument extends IPage, Document {}
 
+type TargetAndAncestorsResult = {
+  targetAndAncestors: PageDocument[]
+  rootPage: PageDocument
+}
 export interface PageModel extends Model<PageDocument> {
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
-  findSiblingsByPathAndViewer(path: string | null, user, userGroups?): Promise<PageDocument[]>
-  findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<PageDocument[]>
+  findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
 }
@@ -92,6 +95,7 @@ const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): strin
   return collectAncestorPaths(parentPath, ancestorPaths);
 };
 
+
 const hasSlash = (str: string): boolean => {
   return str.includes('/');
 };
@@ -99,16 +103,29 @@ const hasSlash = (str: string): boolean => {
 /*
  * Generate RE2 instance for one level lower path
  */
-const generateChildrenRegExp = (path: string): RE2 => {
-  // https://regex101.com/r/iu1vYF/1
-  // ex. / OR /any_level1
-  if (isTopPage(path)) return new RE2(/^\/[^\\/]*$/);
+const generateChildrenRE2 = (path: string): RE2 => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RE2(/^\/[^/]+$/);
 
   // https://regex101.com/r/mrDJrx/1
   // ex. /parent/any_child OR /any_level1
   return new RE2(`^${path}(\\/[^/]+)\\/?$`);
 };
 
+/*
+ * Generate RegExp instance for one level lower path
+ */
+const generateChildrenRegExp = (path: string): RegExp => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
+};
+
 /*
  * Create empty pages if the page in paths didn't exist
  */
@@ -218,30 +235,12 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
-/*
- * Find the siblings including the target page. When top page, it returns /any_level1 pages
- */
-schema.statics.findSiblingsByPathAndViewer = async function(path: string | null, user, userGroups): Promise<PageDocument[]> {
-  if (path == null) {
-    throw new Error('path is required.');
-  }
-
-  const _parentPath = nodePath.dirname(path);
-  const parentPath = isTopPage(_parentPath) ? '' : _parentPath;
-
-  const regexp = generateChildrenRegExp(parentPath);
-
-  const queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source, $options: regexp.flags } }));
-  await addViewerCondition(queryBuilder, user, userGroups);
-
-  return queryBuilder.query.lean().exec();
-};
 
 /*
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * The result will include the target as well
  */
-schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<PageDocument[]> {
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
   let path;
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
@@ -259,7 +258,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
-  const _ancestors: PageDocument[] = await queryBuilder
+  const _targetAndAncestors: PageDocument[] = await queryBuilder
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortAncestorPages()
@@ -269,10 +268,11 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
   // no same path pages
   const ancestorsMap = new Map<string, PageDocument>();
-  _ancestors.forEach(page => ancestorsMap.set(page.path, page));
-  const ancestors = Array.from(ancestorsMap.values());
+  _targetAndAncestors.forEach(page => ancestorsMap.set(page.path, page));
+  const targetAndAncestors = Array.from(ancestorsMap.values());
+  const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
 
-  return ancestors;
+  return { targetAndAncestors, rootPage };
 };
 
 /*
@@ -282,7 +282,7 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   let queryBuilder: PageQueryBuilder;
   if (hasSlash(parentPathOrId)) {
     const path = parentPathOrId;
-    const regexp = generateChildrenRegExp(path);
+    const regexp = generateChildrenRE2(path);
     queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source } }));
   }
   else {
@@ -294,7 +294,34 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   return queryBuilder.query.lean().exec();
 };
 
-// TODO: implement findAncestorsChildrenByPathAndViewer using lean()
+schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
+  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path);
+  const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
+
+  // get pages at once
+  const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
+  await addViewerCondition(queryBuilder, user, userGroups);
+  const _pages = await queryBuilder
+    .addConditionToMinimizeDataForRendering()
+    .query
+    .lean()
+    .exec();
+  // mark target
+  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+    if (page.path === path) {
+      page.isTarget = true;
+    }
+    return page;
+  });
+
+  // make map
+  const pathToChildren: Record<string, PageDocument[]> = {};
+  ancestorPaths.forEach((path) => {
+    pathToChildren[path] = pages.filter(page => nodePath.dirname(page.path) === path);
+  });
+
+  return pathToChildren;
+};
 
 
 /*

+ 8 - 2
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -47,9 +47,15 @@ export default (crowi: Crowi): Router => {
 
     const Page: PageModel = crowi.model('Page');
 
-    const ancestorsChildren: Record<string, PageDocument[]> = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+    try {
+      const ancestorsChildren = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+      return res.apiv3({ ancestorsChildren });
+    }
+    catch (err) {
+      logger.error('Failed to get ancestorsChildren.', err);
+      return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
+    }
 
-    return res.apiv3({ ancestorsChildren });
   });
 
   /*

+ 2 - 2
packages/app/src/server/routes/page.js

@@ -265,13 +265,13 @@ module.exports = function(crowi, app) {
   }
 
   async function addRenderVarsForPageTree(renderVars, path) {
-    const targetAndAncestors = await Page.findTargetAndAncestorsByPathOrId(path);
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
 
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
       throw new Error('Ancestors must have at least one page.');
     }
 
-    renderVars.targetAndAncestors = targetAndAncestors;
+    renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
   }
 
   function replacePlaceholdersOfTemplate(template, req) {

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

@@ -4,5 +4,5 @@ import { useStaticSWR } from './use-static-swr';
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
-  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
+  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData || null);
 };

+ 3 - 2
packages/app/src/stores/page-listing.tsx

@@ -5,14 +5,15 @@ import { AncestorsChildrenResult } from '../interfaces/page-listing-results';
 
 
 export const useSWRxPageAncestorsChildren = (
-    path: string | null,
+    path: string,
 ): SWRResponse<AncestorsChildrenResult, Error> => {
   return useSWR(
-    path ? `/page-listing/ancestors-children?path=${path}` : null,
+    `/page-listing/ancestors-children?path=${path}`,
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         ancestorsChildren: response.data.ancestorsChildren,
       };
     }),
+    { revalidateOnFocus: false },
   );
 };

+ 19 - 20
packages/app/src/stores/use-static-swr.tsx

@@ -1,27 +1,26 @@
-import useSWR, {
-  Key, SWRResponse, mutate, useSWRConfig,
+import {
+  Key, SWRConfiguration, SWRResponse, mutate,
 } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 import { Fetcher } from 'swr/dist/types';
 
 
-export const useStaticSWR = <Data, Error>(
-  key: Key,
-  updateData?: Data | Fetcher<Data>,
-  initialData?: Data | Fetcher<Data>,
-): SWRResponse<Data, Error> => {
-  const { cache } = useSWRConfig();
+export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null,
+  configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
 
-  if (updateData == null) {
-    if (cache.get(key) == null && initialData != null) {
-      mutate(key, initialData, false);
-    }
-  }
-  else {
-    mutate(key, updateData);
+export function useStaticSWR<Data, Error>(
+    ...args: readonly [Key]
+    | readonly [Key, Data | Fetcher<Data> | null]
+    | readonly [Key, Data | Fetcher<Data> | null, SWRConfiguration<Data, Error> | undefined]
+): SWRResponse<Data, Error> {
+  const [key, fetcher, configuration] = args;
+
+  const fetcherFixed = fetcher || configuration?.fetcher;
+  if (fetcherFixed != null) {
+    mutate(key, fetcherFixed);
   }
 
-  return useSWR(key, null, {
-    revalidateOnFocus: false,
-    revalidateOnReconnect: false,
-  });
-};
+  return useSWRImmutable(key, null, configuration);
+}

+ 0 - 3
packages/app/src/utils/swr-utils.ts

@@ -1,9 +1,6 @@
 import { SWRConfiguration } from 'swr';
 
-import axios from './axios';
 
 export const swrGlobalConfiguration: SWRConfiguration = {
-  fetcher: url => axios.get(url).then(res => res.data),
-  revalidateOnFocus: false,
   errorRetryCount: 1,
 };