Kaynağa Gözat

Merge pull request #4632 from weseek/feat/pt-fetch-initial-data

feat: Pt fetch initial data (client)
Haku Mizuki 4 yıl önce
ebeveyn
işleme
2acb7cb9f7

+ 1 - 0
packages/app/src/client/services/PageContainer.js

@@ -82,6 +82,7 @@ export default class PageContainer extends Container {
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
+      targetAndAncestors: JSON.parse(mainContent.getAttribute('data-target-and-ancestors') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,

+ 2 - 4
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -10,9 +10,7 @@ interface ItemProps {
 const Item = memo<ItemProps>((props: ItemProps) => {
   const { itemNode, isOpen = false } = props;
 
-  const { page, children, isPartialChildren } = itemNode;
-
-  // TODO: fetch children if isPartialChildren
+  const { page, children } = itemNode;
 
   if (page == null) {
     return null;
@@ -31,7 +29,7 @@ const Item = memo<ItemProps>((props: ItemProps) => {
       {
         itemNode.hasChildren() && (children as ItemNode[]).map(node => (
           <Item
-            key={node.page.path}
+            key={node.page._id}
             itemNode={node}
             isOpen={false}
           />

+ 8 - 6
packages/app/src/components/Sidebar/PageTree/ItemNode.ts

@@ -1,6 +1,7 @@
-import { IPage } from '~/interfaces/page';
+import { IPage } from '../../../interfaces/page';
+import { HasObjectId } from '../../../interfaces/has-object-id';
 
-type IPageForItem = Partial<IPage> & {isTarget?: boolean};
+type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
 
 export class ItemNode {
 
@@ -8,16 +9,17 @@ export class ItemNode {
 
   children?: ItemNode[];
 
-  isPartialChildren?: boolean;
-
-  constructor(page: IPageForItem, children: ItemNode[] = [], isPartialChildren = false) {
+  constructor(page: IPageForItem, children: ItemNode[] = []) {
     this.page = page;
     this.children = children;
-    this.isPartialChildren = isPartialChildren;
   }
 
   hasChildren(): boolean {
     return this.children != null && this.children?.length > 0;
   }
 
+  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
+    return pages.map(page => new ItemNode(page));
+  }
+
 }

+ 49 - 36
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -3,44 +3,22 @@ import React, { FC } from 'react';
 import { IPage } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
+import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
+import { useTargetAndAncestors } from '../../../stores/context';
 
 /*
- * Mock data
+ * Utility to generate initial node and return
  */
-const ancestors: (Partial<IPage> & {isTarget?: boolean})[] = [
-  {
-    path: '/A/B',
-    isEmpty: false,
-    grant: 1,
-  },
-  {
-    path: '/A',
-    isEmpty: false,
-    grant: 1,
-  },
-  {
-    path: '/',
-    isEmpty: false,
-    grant: 1,
-  },
-];
-
-
-/*
- * Utility to generate node tree and return the root node
- */
-const generateInitialTreeFromAncestors = (ancestors: Partial<IPage>[]): ItemNode => {
-  const rootPage = ancestors[ancestors.length - 1]; // the last item is the root
+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 ancestorNodes = ancestors.map((page, i): ItemNode => {
-    // isPartialChildren will be false for the target page
-    const isPartialChildren = i !== 0;
-    return new ItemNode(page, [], isPartialChildren);
+  const nodes = targetAndAncestors.map((page): ItemNode => {
+    return new ItemNode(page, []);
   });
 
   // update children for each node
-  const rootNode = ancestorNodes.reduce((child, parent) => {
+  const rootNode = nodes.reduce((child, parent) => {
     parent.children = [child];
     return parent;
   });
@@ -52,17 +30,52 @@ const generateInitialTreeFromAncestors = (ancestors: Partial<IPage>[]): ItemNode
  * ItemsTree
  */
 const ItemsTree: FC = () => {
-  // TODO: fetch ancestors using swr
-  if (ancestors == null) return null;
+  // TODO: get from props
+  const path = '/Sandbox/Bootstrap4';
 
-  // create node tree
-  const rootNode = generateInitialTreeFromAncestors(ancestors);
+  // initial request
+  const { data: targetAndAncestors, error } = useTargetAndAncestors();
 
-  const isOpen = true;
+  // secondary request
+  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(targetAndAncestors != null ? path : null);
+
+  if (error != null || error2 != null) {
+    return null;
+  }
 
+  if (targetAndAncestors == null) {
+    return null;
+  }
+
+  const initialNode = generateInitialNode(targetAndAncestors);
+
+  /*
+   * when second SWR resolved
+   */
+  if (ancestorsChildrenData != null) {
+    // increment initialNode
+    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 isOpen = true;
   return (
     <>
-      <Item key={rootNode.page.path} itemNode={rootNode} isOpen={isOpen} />
+      <Item key={initialNode.page.path} itemNode={initialNode} isOpen={isOpen} />
     </>
   );
 };

+ 7 - 0
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { useTargetAndAncestors } from '../../stores/context';
 
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
@@ -13,6 +14,12 @@ import PageTree from './PageTree';
 const SidebarContents = (props) => {
   const { navigationContainer, isSharedUser } = props;
 
+  const pageContainer = navigationContainer.getPageContainer();
+
+  const { targetAndAncestors } = pageContainer.state;
+
+  useTargetAndAncestors(targetAndAncestors);
+
   if (isSharedUser) {
     return null;
   }

+ 3 - 0
packages/app/src/interfaces/has-object-id.ts

@@ -0,0 +1,3 @@
+export interface HasObjectId {
+  _id: string,
+}

+ 10 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -0,0 +1,10 @@
+import { IPage } from './page';
+import { HasObjectId } from './has-object-id';
+
+
+type ParentPath = string;
+export interface AncestorsChildrenResult {
+  ancestorsChildren: Record<ParentPath, Partial<IPage & HasObjectId>[]>
+}
+
+export type TargetAndAncestors = Partial<IPage & HasObjectId>[];

+ 6 - 0
packages/app/src/server/models/obsolete-page.js

@@ -228,6 +228,12 @@ export class PageQueryBuilder {
     return this;
   }
 
+  addConditionToMinimizeDataForRendering() {
+    this.query = this.query.select('_id path isEmpty grant');
+
+    return this;
+  }
+
   addConditionToListByPathsArray(paths) {
     this.query = this.query
       .and({

+ 9 - 3
packages/app/src/server/models/page.ts

@@ -38,8 +38,9 @@ export interface PageModel extends Model<PageDocument> {
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
   findSiblingsByPathAndViewer(path: string | null, user, userGroups?): Promise<PageDocument[]>
-  findAncestorsByPathOrId(pathOrId: string): Promise<PageDocument[]>
+  findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
 }
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -238,13 +239,14 @@ schema.statics.findSiblingsByPathAndViewer = async function(path: string | null,
 
 /*
  * 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.findAncestorsByPathOrId = async function(pathOrId: string): Promise<PageDocument[]> {
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<PageDocument[]> {
   let path;
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const page = await this.findOne({ _id });
-    if (page == null) throw Error('Page not found.');
+    if (page == null) throw new Error('Page not found.');
 
     path = page.path;
   }
@@ -253,11 +255,13 @@ schema.statics.findAncestorsByPathOrId = async function(pathOrId: string): Promi
   }
 
   const ancestorPaths = collectAncestorPaths(path);
+  ancestorPaths.push(path); // include target
 
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
   const _ancestors: PageDocument[] = await queryBuilder
     .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToMinimizeDataForRendering()
     .addConditionToSortAncestorPages()
     .query
     .lean()
@@ -290,6 +294,8 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   return queryBuilder.query.lean().exec();
 };
 
+// TODO: implement findAncestorsChildrenByPathAndViewer using lean()
+
 
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance

+ 5 - 51
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,4 @@
 import express, { Request, Router } from 'express';
-import { pagePathUtils } from '@growi/core';
 import { query, oneOf } from 'express-validator';
 
 import { PageDocument, PageModel } from '../../models/page';
@@ -8,8 +7,6 @@ import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
 
-const { isTopPage } = pagePathUtils;
-
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 /*
@@ -23,8 +20,7 @@ interface AuthorizedRequest extends Request {
  * Validators
  */
 const validator = {
-  pageIdAndPathRequired: [
-    query('id').isMongoId().withMessage('id is required'),
+  pagePathRequired: [
     query('path').isString().withMessage('path is required'),
   ],
   pageIdOrPathRequired: oneOf([
@@ -46,56 +42,14 @@ export default (crowi: Crowi): Router => {
 
 
   // eslint-disable-next-line max-len
-  router.get('/siblings', accessTokenParser, loginRequiredStrictly, ...validator.pageIdAndPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-    const { id, path } = req.query;
-
-    const Page: PageModel = crowi.model('Page');
-
-    let siblings: PageDocument[];
-    let target: PageDocument;
-    try {
-      siblings = await Page.findSiblingsByPathAndViewer(path as string, req.user);
-
-      target = siblings.filter(page => page._id.toString() === id)?.[0];
-      if (target == null) {
-        throw Error('Target must exist.');
-      }
-    }
-    catch (err) {
-      logger.error('Error occurred while finding pages.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while finding pages.'));
-    }
-
-    if (isTopPage(path as string)) {
-      siblings = siblings.filter(page => !isTopPage(page.path));
-    }
-
-    return res.apiv3({ target, siblings });
-  });
-
-  /*
-   * In most cases, using path should be prioritized
-   */
-  // eslint-disable-next-line max-len
-  router.get('/ancestors', accessTokenParser, loginRequiredStrictly, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-    const { id, path } = req.query;
+  router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+    const { path } = req.query;
 
     const Page: PageModel = crowi.model('Page');
 
-    let ancestors: PageDocument[];
-    try {
-      ancestors = await Page.findAncestorsByPathOrId((path || id) as string);
-
-      if (ancestors.length === 0 && !isTopPage(path as string)) {
-        throw Error('Ancestors must have at least one page.');
-      }
-    }
-    catch (err) {
-      logger.error('Error occurred while finding pages.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while finding pages.'));
-    }
+    const ancestorsChildren: Record<string, PageDocument[]> = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
 
-    return res.apiv3({ ancestors });
+    return res.apiv3({ ancestorsChildren });
   });
 
   /*

+ 15 - 1
packages/app/src/server/routes/page.js

@@ -4,7 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import UpdatePost from '../models/update-post';
 
-const { isCreatablePage } = pagePathUtils;
+const { isCreatablePage, isTopPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -264,6 +264,16 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
   }
 
+  async function addRenderVarsForPageTree(renderVars, path) {
+    const targetAndAncestors = await Page.findTargetAndAncestorsByPathOrId(path);
+
+    if (targetAndAncestors.length === 0 && !isTopPage(path)) {
+      throw new Error('Ancestors must have at least one page.');
+    }
+
+    renderVars.targetAndAncestors = targetAndAncestors;
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -329,6 +339,8 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
+    await addRenderVarsForPageTree(renderVars, portalPath);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
@@ -383,6 +395,8 @@ module.exports = function(crowi, app) {
       await addRenderVarsForUserPage(renderVars, page);
     }
 
+    await addRenderVarsForPageTree(renderVars, path);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }

+ 1 - 0
packages/app/src/server/views/widget/page_content.html

@@ -27,6 +27,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-target-and-ancestors="{% if targetAndAncestors %}{{ targetAndAncestors|json }}{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main d-flex"

+ 8 - 0
packages/app/src/stores/context.tsx

@@ -0,0 +1,8 @@
+import { SWRResponse } from 'swr';
+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);
+};

+ 18 - 0
packages/app/src/stores/page-listing.tsx

@@ -0,0 +1,18 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '../client/util/apiv3-client';
+import { AncestorsChildrenResult } from '../interfaces/page-listing-results';
+
+
+export const useSWRxPageAncestorsChildren = (
+    path: string | null,
+): SWRResponse<AncestorsChildrenResult, Error> => {
+  return useSWR(
+    path ? `/page-listing/ancestors-children?path=${path}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        ancestorsChildren: response.data.ancestorsChildren,
+      };
+    }),
+  );
+};

+ 27 - 0
packages/app/src/stores/use-static-swr.tsx

@@ -0,0 +1,27 @@
+import useSWR, {
+  Key, SWRResponse, mutate, useSWRConfig,
+} from 'swr';
+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();
+
+  if (updateData == null) {
+    if (cache.get(key) == null && initialData != null) {
+      mutate(key, initialData, false);
+    }
+  }
+  else {
+    mutate(key, updateData);
+  }
+
+  return useSWR(key, null, {
+    revalidateOnFocus: false,
+    revalidateOnReconnect: false,
+  });
+};