Lsx.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import React, {
  2. useCallback, useEffect, useMemo, useState,
  3. } from 'react';
  4. import * as url from 'url';
  5. import { IPage, pathUtils } from '@growi/core';
  6. import axios from 'axios';
  7. import { LsxListView } from './LsxPageList/LsxListView';
  8. import { PageNode } from './PageNode';
  9. import { LsxContext } from './lsx-context';
  10. import { getInstance as getTagCacheManager } from './tag-cache-manager';
  11. import styles from './Lsx.module.scss';
  12. const tagCacheManager = getTagCacheManager();
  13. /**
  14. * compare whether path1 and path2 is the same
  15. *
  16. * @param {string} path1
  17. * @param {string} path2
  18. * @returns
  19. *
  20. * @memberOf Lsx
  21. */
  22. function isEquals(path1: string, path2: string) {
  23. return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
  24. }
  25. function getParentPath(path: string) {
  26. return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
  27. }
  28. /**
  29. * generate PageNode instances for target page and the ancestors
  30. *
  31. * @param {any} pathToNodeMap
  32. * @param {any} rootPagePath
  33. * @param {any} pagePath
  34. * @returns
  35. * @memberof Lsx
  36. */
  37. function generatePageNode(pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string): PageNode | null {
  38. // exclude rootPagePath itself
  39. if (isEquals(pagePath, rootPagePath)) {
  40. return null;
  41. }
  42. // return when already registered
  43. if (pathToNodeMap[pagePath] != null) {
  44. return pathToNodeMap[pagePath];
  45. }
  46. // generate node
  47. const node = new PageNode(pagePath);
  48. pathToNodeMap[pagePath] = node;
  49. /*
  50. * process recursively for ancestors
  51. */
  52. // get or create parent node
  53. const parentPath = getParentPath(pagePath);
  54. const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath);
  55. // associate to patent
  56. if (parentNode != null) {
  57. parentNode.children.push(node);
  58. }
  59. return node;
  60. }
  61. function generatePageNodeTree(rootPagePath: string, pages: IPage[]) {
  62. const pathToNodeMap: Record<string, PageNode> = {};
  63. pages.forEach((page) => {
  64. // add slash ensure not to forward match to another page
  65. // e.g. '/Java/' not to match to '/JavaScript'
  66. const pagePath = pathUtils.addTrailingSlash(page.path);
  67. const node = generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
  68. // exclude rootPagePath itself
  69. if (node == null) {
  70. return;
  71. }
  72. // set the Page substance
  73. node.page = page;
  74. });
  75. // return root objects
  76. const rootNodes: PageNode[] = [];
  77. Object.keys(pathToNodeMap).forEach((pagePath) => {
  78. // exclude '/'
  79. if (pagePath === '/') {
  80. return;
  81. }
  82. const parentPath = getParentPath(pagePath);
  83. // pick up what parent doesn't exist
  84. if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
  85. rootNodes.push(pathToNodeMap[pagePath]);
  86. }
  87. });
  88. return rootNodes;
  89. }
  90. type Props = {
  91. children: React.ReactNode,
  92. className?: string,
  93. prefix: string,
  94. num?: string,
  95. depth?: string,
  96. sort?: string,
  97. reverse?: string,
  98. filter?: string,
  99. forceToFetchData?: boolean,
  100. };
  101. type StateCache = {
  102. isError: boolean,
  103. errorMessage: string,
  104. basisViewersCount?: number,
  105. nodeTree?: PageNode[],
  106. }
  107. export const Lsx = ({
  108. prefix,
  109. num, depth, sort, reverse, filter,
  110. ...props
  111. }: Props): JSX.Element => {
  112. const [isLoading, setLoading] = useState(false);
  113. const [isError, setError] = useState(false);
  114. const [isCacheExists, setCacheExists] = useState(false);
  115. const [nodeTree, setNodeTree] = useState<PageNode[]|undefined>();
  116. const [basisViewersCount, setBasisViewersCount] = useState<number|undefined>();
  117. const [errorMessage, setErrorMessage] = useState('');
  118. const { forceToFetchData } = props;
  119. const lsxContext = useMemo(() => {
  120. const options = {
  121. num, depth, sort, reverse, filter,
  122. };
  123. return new LsxContext(prefix, options);
  124. }, [depth, filter, num, prefix, reverse, sort]);
  125. const retrieveDataFromCache = useCallback(() => {
  126. // get state object cache
  127. const stateCache = tagCacheManager.getStateCache(lsxContext) as StateCache | null;
  128. // instanciate PageNode
  129. if (stateCache != null && stateCache.nodeTree != null) {
  130. stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
  131. return PageNode.instanciateFrom(obj);
  132. });
  133. }
  134. return stateCache;
  135. }, [lsxContext]);
  136. const loadData = useCallback(async() => {
  137. setLoading(true);
  138. // add slash ensure not to forward match to another page
  139. // ex: '/Java/' not to match to '/JavaScript'
  140. const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
  141. let newNodeTree: PageNode[] = [];
  142. try {
  143. const result = await axios.get('/_api/plugins/lsx', {
  144. params: {
  145. pagePath,
  146. options: lsxContext.options,
  147. },
  148. });
  149. newNodeTree = generatePageNodeTree(pagePath, result.data.pages);
  150. setNodeTree(newNodeTree);
  151. setBasisViewersCount(result.data.toppageViewersCount);
  152. setError(false);
  153. // store to sessionStorage
  154. tagCacheManager.cacheState(lsxContext, {
  155. isError: false,
  156. errorMessage: '',
  157. basisViewersCount,
  158. nodeTree: newNodeTree,
  159. });
  160. }
  161. catch (error) {
  162. setError(true);
  163. setErrorMessage(error.message);
  164. // store to sessionStorage
  165. tagCacheManager.cacheState(lsxContext, {
  166. isError: true,
  167. errorMessage: error.message,
  168. });
  169. }
  170. finally {
  171. setLoading(false);
  172. }
  173. }, [basisViewersCount, lsxContext]);
  174. useEffect(() => {
  175. // get state object cache
  176. const stateCache = retrieveDataFromCache();
  177. if (stateCache != null) {
  178. setCacheExists(true);
  179. setNodeTree(stateCache.nodeTree);
  180. setError(stateCache.isError);
  181. setErrorMessage(stateCache.errorMessage);
  182. // switch behavior by forceToFetchData
  183. if (!forceToFetchData) {
  184. return; // go to render()
  185. }
  186. }
  187. loadData();
  188. }, [forceToFetchData, loadData, retrieveDataFromCache]);
  189. const renderContents = () => {
  190. if (isError) {
  191. return (
  192. <div className="text-warning">
  193. <i className="fa fa-exclamation-triangle fa-fw"></i>
  194. {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
  195. </div>
  196. );
  197. }
  198. const showListView = nodeTree != null && (!isLoading || nodeTree.length > 0);
  199. return (
  200. <>
  201. { isLoading && (
  202. <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
  203. <small>
  204. <i className="fa fa-spinner fa-pulse mr-1"></i>
  205. {lsxContext.toString()}
  206. { isCacheExists && <>&nbsp;(Showing cache..)</> }
  207. </small>
  208. </div>
  209. ) }
  210. { showListView && (
  211. <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />
  212. ) }
  213. </>
  214. );
  215. };
  216. return <div className={`lsx ${styles.lsx}`}>{renderContents()}</div>;
  217. };