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

Merge branch 'master' into feat/can-use-recommended-sanitizer

Shun Miyazawa пре 3 година
родитељ
комит
746b3aba8c
24 измењених фајлова са 402 додато и 200 уклоњено
  1. 1 1
      packages/app/src/client/services/page-operation.ts
  2. 25 36
      packages/app/src/components/PageEditor.tsx
  3. 3 2
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  4. 30 38
      packages/app/src/components/PageEditorByHackmd.tsx
  5. 10 5
      packages/app/src/components/SavePageControls.tsx
  6. 4 2
      packages/app/src/components/Sidebar.tsx
  7. 12 0
      packages/app/src/components/Sidebar/CustomSidebar.module.scss
  8. 11 15
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  9. 22 20
      packages/app/src/components/Sidebar/PageTree.tsx
  10. 14 1
      packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss
  11. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  12. 17 0
      packages/app/src/components/Sidebar/RecentChanges.module.scss
  13. 43 67
      packages/app/src/components/Sidebar/RecentChanges.tsx
  14. 14 0
      packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx
  15. 18 0
      packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx
  16. 18 0
      packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx
  17. 40 0
      packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx
  18. 6 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss
  19. 50 0
      packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx
  20. 23 0
      packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  21. 10 0
      packages/app/src/components/Sidebar/Tag.module.scss
  22. 5 10
      packages/app/src/components/Sidebar/Tag.tsx
  23. 1 1
      packages/app/src/components/Skeleton.tsx
  24. 21 0
      packages/app/src/styles/_mixins.scss

+ 1 - 1
packages/app/src/client/services/page-operation.ts

@@ -192,7 +192,7 @@ export const useUpdateStateAfterSave = () => {
       remoteRevisionBody: updatedPage.revision.body,
       remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
       remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
-      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced.toString(),
+      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced?.toString(),
       hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
     };
 

+ 25 - 36
packages/app/src/components/PageEditor.tsx

@@ -69,7 +69,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -82,7 +82,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
@@ -117,8 +116,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
-  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
-
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -152,18 +149,21 @@ const PageEditor = React.memo((): JSX.Element => {
 
   }, [socket, checkIsConflict]);
 
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   // register to facade
   useEffect(() => {
     // for markdownRenderer
@@ -189,30 +189,19 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
-    if (grantData == null || isSlackEnabled == null || currentPathname == null) {
+  const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
+    if (currentPathname == null || optionsToSave == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
     }
 
-    const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
-    const grantedGroup = grantData?.grantedGroup;
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled,
-      slackChannels,
-      grant: grant || 1,
-      pageTags: pageTags || [],
-      grantUserGroupId: grantedGroup?.id,
-      grantUserGroupName: grantedGroup?.name,
-      ...opts,
-    };
+    const options = Object.assign(optionsToSave, opts);
 
     try {
       const { page } = await saveOrUpdate(
         markdownToSave.current,
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        optionsToSave,
+        options,
       );
 
       return page;
@@ -232,9 +221,9 @@ const PageEditor = React.memo((): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
-  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+  const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
       return;
     }
@@ -251,7 +240,7 @@ const PageEditor = React.memo((): JSX.Element => {
       updateStateAfterSave(page._id);
     }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
@@ -263,7 +252,7 @@ const PageEditor = React.memo((): JSX.Element => {
       updateStateAfterSave(page._id);
       toastSuccess(t('toaster.save_succeeded'));
     }
-  }, [editorMode, save, t, useUpdateStateAfterSave]);
+  }, [editorMode, save, t, updateStateAfterSave]);
 
 
   /**
@@ -540,7 +529,7 @@ const PageEditor = React.memo((): JSX.Element => {
         isOpen={conflictDiffModalStatus?.isOpened}
         onClose={() => closeConflictDiffModal()}
         markdownOnEdit={markdownToPreview}
-        optionsToSave={undefined} // replace undefined
+        optionsToSave={optionsToSave}
         afterResolvedHandler={afterResolvedHandler}
       />
     </div>

+ 3 - 2
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
+import { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
@@ -12,7 +13,7 @@ import {
 } from '~/stores/ui';
 
 
-const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
 const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
@@ -129,7 +130,7 @@ const EditorNavbarBottom = (): JSX.Element => {
               )}
             </div>
           ))}
-          <SavePageControls />
+          <SavePageControls slackChannels={slackChannelsStr} />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>

+ 30 - 38
packages/app/src/components/PageEditorByHackmd.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useRef, useState, useEffect,
+  useCallback, useRef, useState, useEffect, useMemo,
 } from 'react';
 
 import EventEmitter from 'events';
@@ -19,13 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useRemoteRevisionId, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -56,12 +56,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
-  const { data: grant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
 
@@ -71,8 +70,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
 
-  const slackChannels = slackChannelsData?.toString();
-
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitializing, setIsInitializing] = useState(false);
   // for error
@@ -92,29 +89,34 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
+  const optionsToSave = useMemo((): OptionsToSave | undefined => {
+    if (grantData == null) {
+      return;
+    }
+    const optionsToSave = {
+      isSlackEnabled: isSlackEnabled ?? false,
+      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
+      grant: grantData.grant,
+      pageTags: pageTags ?? [],
+      grantUserGroupId: grantData.grantedGroup?.id,
+      grantUserGroupName: grantData.grantedGroup?.name,
+    };
+    return optionsToSave;
+  }, [grantData, isSlackEnabled, pageTags]);
+
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.HackMD) { return }
 
     try {
-      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null
-          || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null) {
+      if (currentPathname == null || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null || optionsToSave == null) {
         throw new Error('Some materials to save are invalid');
       }
 
-      const optionsToSave: OptionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-        ...opts,
-      };
+      const options = Object.assign(optionsToSave, opts, { isSyncRevisionToHackmd: true });
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, options);
       await mutatePageData();
       await mutateTagsInfo();
 
@@ -135,7 +137,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, revisionIdHackmdSynced, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -238,19 +240,12 @@ export const PageEditorByHackmd = (): JSX.Element => {
     try {
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       if (
-        isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
-        || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
+        pageId == null || revisionIdHackmdSynced == null || currentPagePathOrPathname == null || optionsToSave == null
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = {
-        isSlackEnabled,
-        slackChannels,
-        grant: grant.grant,
-        grantUserGroupId: grant.grantedGroup?.id,
-        grantUserGroupName: grant.grantedGroup?.name,
-        pageTags: pageTags ?? [],
-        isSyncRevisionToHackmd: true,
-      };
-      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
+
+      const options = Object.assign(optionsToSave, { isSyncRevisionToHackmd: true });
+
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, options);
 
       // update pageData
       mutatePageData(res);
@@ -267,10 +262,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [
-    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced,
-    pageTags, saveOrUpdate, mutatePageData, useUpdateStateAfterSave, mutateTagsInfo, t,
-  ]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 10 - 5
packages/app/src/components/SavePageControls.tsx

@@ -30,7 +30,12 @@ const logger = loggerFactory('growi:SavePageControls');
 
 const { isTopPage } = pagePathUtils;
 
-export const SavePageControls = (): JSX.Element | null => {
+export type SavePageControlsProps = {
+  slackChannels: string
+}
+
+export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
+  const { slackChannels } = props;
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
@@ -45,13 +50,13 @@ export const SavePageControls = (): JSX.Element | null => {
 
   const save = useCallback(async(): Promise<void> => {
     // save
-    globalEmitter.emit('saveAndReturnToView');
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { slackChannels });
+  }, [slackChannels]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
-  }, []);
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
+  }, [slackChannels]);
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {

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

@@ -17,6 +17,7 @@ import {
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { SidebarNav } from './Sidebar/SidebarNav';
+import { SidebarSkeleton } from './Sidebar/Skeleton/SidebarSkeleton';
 import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 
 import styles from './Sidebar.module.scss';
@@ -57,8 +58,9 @@ const GlobalNavigation = () => {
 
 const SidebarContentsWrapper = () => {
   const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
-    .then(mod => mod.StickyStretchableScroller), { ssr: false });
-  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
+    .then(mod => mod.StickyStretchableScroller), { ssr: false, loading: () => <SidebarSkeleton /> });
+  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents')
+    .then(mod => mod.SidebarContents), { ssr: false, loading: () => <SidebarSkeleton /> });
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {

+ 12 - 0
packages/app/src/components/Sidebar/CustomSidebar.module.scss

@@ -1,7 +1,19 @@
 @use '~/styles/organisms/wiki-custom-sidebar.scss';
+@use '~/styles/mixins' as *;
 
 .grw-custom-sidebar-content :global {
   .wiki {
     @extend %grw-custom-sidebar-content;
   }
+
+  .grw-custom-sidebar-skeleton-text {
+    @include grw-skeleton-text($font-size:15px, $line-height:21.42px);
+    max-width: 160px;
+    margin: 15px 0;
+  }
+
+  .grw-custom-sidebar-skeleton-text-full {
+    @extend .grw-custom-sidebar-skeleton-text;
+    max-width: 100%;
+  }
 }

+ 11 - 15
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import CustomSidebarContentSkeleton from './Skeleton/CustomSidebarContentSkeleton';
 
 import styles from './CustomSidebar.module.scss';
 
@@ -19,11 +21,9 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 
 const SidebarNotFound = () => {
   return (
-    <div className="grw-sidebar-content-header h5 text-center p-3">
+    <div className="grw-sidebar-content-header h5 text-center py-3">
       <Link href="/Sidebar#edit">
-        <a href="/Sidebar#edit">
-          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-        </a>
+        <a><i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page</a>
       </Link>
     </div>
   );
@@ -43,28 +43,24 @@ const CustomSidebar: FC = () => {
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">
           {t('CustomSidebar')}
-          <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
+          <Link href="/Sidebar"><a className="h6 ml-2"><i className="icon-pencil"></i></a></Link>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
       </div>
 
       {
         isLoading && (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-          </div>
+          <CustomSidebarContentSkeleton />
         )
       }
 
       {
         (!isLoading && markdown != null) && (
-          <div className={`p-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+          <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
             <RevisionRenderer
               rendererOptions={rendererOptions}
               markdown={markdown}
@@ -78,7 +74,7 @@ const CustomSidebar: FC = () => {
           <SidebarNotFound />
         )
       }
-    </>
+    </div>
   );
 };
 

+ 22 - 20
packages/app/src/components/Sidebar/PageTree.tsx

@@ -10,6 +10,17 @@ import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 import ItemsTree from './PageTree/ItemsTree';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
+import PageTreeContentSkeleton from './Skeleton/PageTreeContentSkeleton';
+
+const PageTreeHeader = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-sidebar-content-header py-3 d-flex">
+      <h3 className="mb-0">{t('Page Tree')}</h3>
+    </div>
+  );
+};
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -24,14 +35,10 @@ const PageTree: FC = memo(() => {
 
   if (migrationStatus == null) {
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
-        <div className="text-muted text-center mt-3">
-          <i className="fa fa-lg fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </>
+      <div className="px-3">
+        <PageTreeHeader />
+        <PageTreeContentSkeleton />
+      </div>
     );
   }
 
@@ -39,15 +46,13 @@ const PageTree: FC = memo(() => {
     // TODO : improve design
     // Story : https://redmine.weseek.co.jp/issues/83755
     return (
-      <>
-        <div className="grw-sidebar-content-header p-3">
-          <h3 className="mb-0">{t('Page Tree')}</h3>
-        </div>
+      <div className="px-3">
+        <PageTreeHeader />
         <div className="mt-5 mx-2 text-center">
           <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
           <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
         </div>
-      </>
+      </div>
     );
   }
 
@@ -61,11 +66,8 @@ const PageTree: FC = memo(() => {
   const path = currentPath || '/';
 
   return (
-    <>
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Page Tree')}</h3>
-      </div>
-
+    <div className="px-3">
+      <PageTreeHeader />
       <ItemsTree
         isEnableActions={!isGuestUser}
         targetPath={path}
@@ -74,13 +76,13 @@ const PageTree: FC = memo(() => {
       />
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top p-3 w-100">
+        <div className="grw-pagetree-footer border-top py-3 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />
           </div>
         </div>
       )}
-    </>
+    </div>
   );
 });
 

+ 14 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

@@ -1,9 +1,22 @@
 @use '~/styles/variables' as var;
+@use '~/styles/mixins' as *;
 $grw-sidebar-content-header-height: 58px;
 $grw-sidebar-content-footer-height: 50px;
 $grw-pagetree-item-padding-left: 10px;
+$grw-pagetree-item-container-height: 40px;
 
 .grw-pagetree {
+
+  .grw-pagetree-item-skeleton-text {
+    @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height);
+    padding-left: 12px;
+  }
+
+  .grw-pagetree-item-skeleton-text-child {
+    @extend .grw-pagetree-item-skeleton-text;
+    padding-left: 12px + $grw-pagetree-item-padding-left;
+  }
+
   :global {
     min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
@@ -56,7 +69,7 @@ $grw-pagetree-item-padding-left: 10px;
     .grw-pagetree-item-container {
       .grw-triangle-container {
         min-width: 35px;
-        height: 40px;
+        height: $grw-pagetree-item-container-height;
       }
     }
   }

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

@@ -24,6 +24,8 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import PageTreeContentSkeleton from '../Skeleton/PageTreeContentSkeleton';
+
 import Item from './Item';
 import { ItemNode } from './ItemNode';
 
@@ -272,7 +274,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group p-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
         <Item
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
@@ -288,7 +290,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     );
   }
 
-  return <></>;
+  return <PageTreeContentSkeleton />;
 };
 
 export default ItemsTree;

+ 17 - 0
packages/app/src/components/Sidebar/RecentChanges.module.scss

@@ -1,3 +1,5 @@
+@use '~/styles/mixins' as *;
+
 .grw-recent-changes-resize-button :global {
   font-size: 12px;
   line-height: normal;
@@ -15,6 +17,21 @@
 }
 
 .list-group-item :global {
+  .grw-recent-changes-skeleton-small {
+    @include grw-skeleton-text($font-size:14px, $line-height:16px);
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-h5 {
+    @include grw-skeleton-h5;
+    max-width: 120px;
+  }
+
+  .grw-recent-changes-skeleton-date {
+    @include grw-skeleton-text($font-size:10px, $line-height:12px);
+    width: 90px;
+  }
+
   .grw-recent-changes-item-lower {
     height: 17.5px;
   }

+ 43 - 67
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -16,6 +16,8 @@ import loggerFactory from '~/utils/logger';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 import InfiniteScroll from './InfiniteScroll';
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 
 import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
@@ -23,11 +25,15 @@ import styles from './RecentChanges.module.scss';
 
 const logger = loggerFactory('growi:History');
 
-type PageItemProps = {
+type PageItemLowerProps = {
   page: IPageHasId,
 }
 
-const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
+type PageItemProps = PageItemLowerProps & {
+  isSmall: boolean
+}
+
+const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
       <div className="d-flex">
@@ -44,8 +50,7 @@ const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
 });
 PageItemLower.displayName = 'PageItemLower';
 
-
-const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
+const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -75,67 +80,38 @@ const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
   });
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} py-3 px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-2">
+          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
           </h5>
-          <div className="grw-tag-labels mt-1 mb-2">
+          {!isSmall && <div className="grw-tag-labels mt-1 mb-2">
             { tagElements }
-          </div>
+          </div>}
           <PageItemLower page={page} />
         </div>
       </div>
     </li>
   );
 });
-LargePageItem.displayName = 'LargePageItem';
-
-
-const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className="grw-page-path-text-muted-container small">
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span><i className="icon-lock ml-2" /></span>;
-  }
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-        <div className="flex-grow-1 ml-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0 text-truncate">
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          <PageItemLower page={page} />
-        </div>
-      </div>
-    </li>
-  );
-});
-SmallPageItem.displayName = 'SmallPageItem';
+PageItem.displayName = 'PageItem';
 
 const RecentChanges = (): JSX.Element => {
+
   const PER_PAGE = 20;
   const { t } = useTranslation();
-  const swr = useSWRInifinitexRecentlyUpdated();
+  const swrInifinitexRecentlyUpdated = useSWRInifinitexRecentlyUpdated();
+  const { data: dataRecentlyUpdated, error, mutate: mutateRecentlyUpdated } = swrInifinitexRecentlyUpdated;
+
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-  const isEmpty = swr.data?.[0].length === 0;
-  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
+  const isEmpty = dataRecentlyUpdated?.[0].length === 0;
+  const isLoading = error == null && dataRecentlyUpdated === undefined;
+  const isReachingEnd = isEmpty || (dataRecentlyUpdated && dataRecentlyUpdated[dataRecentlyUpdated.length - 1]?.length < PER_PAGE);
+
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -153,12 +129,10 @@ const RecentChanges = (): JSX.Element => {
   }, [retrieveSizePreferenceFromLocalStorage]);
 
   return (
-    <div data-testid="grw-recent-changes">
-      <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
-          <i className="icon icon-reload"></i>
-        </button>
+    <div className="px-3" data-testid="grw-recent-changes">
+      <div className="grw-sidebar-content-header py-3 d-flex">
+        <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
+        <SidebarHeaderReloadButton onClick={() => mutateRecentlyUpdated()}/>
         <div className="d-flex align-items-center">
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
             <input
@@ -173,21 +147,23 @@ const RecentChanges = (): JSX.Element => {
           </div>
         </div>
       </div>
-      <div className="grw-recent-changes p-3">
-        <ul className="list-group list-group-flush">
-          <InfiniteScroll
-            swrInifiniteResponse={swr}
-            isReachingEnd={isReachingEnd}
-          >
-            {pages => pages.map(page => (
-              isRecentChangesSidebarSmall
-                ? <SmallPageItem key={page._id} page={page} />
-                : <LargePageItem key={page._id} page={page} />
-            ))
-            }
-          </InfiniteScroll>
-        </ul>
-      </div>
+      {
+        isLoading ? <RecentChangesContentSkeleton /> : (
+          <div className="grw-recent-changes py-3">
+            <ul className="list-group list-group-flush">
+              <InfiniteScroll
+                swrInifiniteResponse={swrInifinitexRecentlyUpdated}
+                isReachingEnd={isReachingEnd}
+              >
+                {pages => pages.map(
+                  page => <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />,
+                )
+                }
+              </InfiniteScroll>
+            </ul>
+          </div>
+        )
+      }
     </div>
   );
 

+ 14 - 0
packages/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+
+type Props = {
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+};
+
+export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+
+  return (
+    <button type="button" className="btn btn-sm ml-auto py-0 grw-btn-reload" onClick={onClick}>
+      <i className="icon icon-reload"></i>
+    </button>
+  );
+};

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/CustomSidebarContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../CustomSidebar.module.scss';
+
+const CustomSidebarContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text-full ${styles['grw-custom-sidebar-skeleton-text-full']}`} />
+      <Skeleton additionalClass={`grw-custom-sidebar-skeleton-text ${styles['grw-custom-sidebar-skeleton-text']}`} />
+    </div>
+  );
+};
+
+export default CustomSidebarContentSkeleton;

+ 18 - 0
packages/app/src/components/Sidebar/Skeleton/PageTreeContentSkeleton.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../PageTree/ItemsTree.module.scss';
+
+const PageTreeContentSkeleton = (): JSX.Element => {
+
+  return (
+    <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} >
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pr-3`} />
+    </ul>
+  );
+};
+
+export default PageTreeContentSkeleton;

+ 40 - 0
packages/app/src/components/Sidebar/Skeleton/RecentChangesContentSkeleton.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../RecentChanges.module.scss';
+
+const SkeletonItem = () => {
+
+  const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
+
+  return (
+    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+      <div className="d-flex w-100">
+        <Skeleton additionalClass='rounded-circle picture' roundedPill />
+        <div className="flex-grow-1 ml-2">
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
+          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
+            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+          </div>
+        </div>
+      </div>
+    </li>
+  );
+};
+
+const RecentChangesContentSkeleton = (): JSX.Element => {
+
+  return (
+    <div className="grw-recent-changes py-3">
+      <ul className="list-group list-group-flush">
+        <SkeletonItem />
+        <SkeletonItem />
+        <SkeletonItem />
+        <li className={'list-group-item p-0'}></li>
+      </ul>
+    </div>);
+};
+
+export default RecentChangesContentSkeleton;

+ 6 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.module.scss

@@ -0,0 +1,6 @@
+@use '~/styles/mixins' as *;
+
+.grw-sidebar-content-header-skeleton {
+  @include grw-skeleton-h3;
+  max-width: 100%;
+}

+ 50 - 0
packages/app/src/components/Sidebar/Skeleton/SidebarSkeleton.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import CustomSidebarContentSkeleton from './CustomSidebarContentSkeleton';
+import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
+import TagContentSkeleton from './TagContentSkeleton';
+
+import styles from './SidebarSkeleton.module.scss';
+
+export const SidebarHeaderSkeleton = (): JSX.Element => {
+  return (
+    <div className="grw-sidebar-content-header py-3">
+      <Skeleton additionalClass={styles['grw-sidebar-content-header-skeleton']} />
+    </div>
+  );
+};
+
+export const SidebarSkeleton = (): JSX.Element => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let SidebarContentSkeleton: () => JSX.Element;
+  switch (currentSidebarContents) {
+
+    case SidebarContentsType.TAG:
+      SidebarContentSkeleton = TagContentSkeleton;
+      break;
+    case SidebarContentsType.RECENT:
+      SidebarContentSkeleton = RecentChangesContentSkeleton;
+      break;
+    case SidebarContentsType.CUSTOM:
+      SidebarContentSkeleton = CustomSidebarContentSkeleton;
+      break;
+    case SidebarContentsType.TREE:
+    default:
+      SidebarContentSkeleton = PageTreeContentSkeleton;
+      break;
+  }
+
+  return (
+    <div className={currentSidebarContents === SidebarContentsType.TAG ? 'px-4' : 'px-3'}>
+      <SidebarHeaderSkeleton />
+      <SidebarContentSkeleton />
+    </div>
+  );
+};

+ 23 - 0
packages/app/src/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Skeleton } from '~/components/Skeleton';
+
+import styles from '../Tag.module.scss';
+
+export const TagListSkeleton = (): JSX.Element => {
+  return (
+    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+  );
+};
+
+const TagContentSkeleton = (): JSX.Element => {
+
+  return (
+    <>
+      <Skeleton additionalClass={`${styles['grw-tag-skeleton-h3']} my-3`} />
+      <TagListSkeleton />
+    </>
+  );
+};
+
+export default TagContentSkeleton;

+ 10 - 0
packages/app/src/components/Sidebar/Tag.module.scss

@@ -0,0 +1,10 @@
+@use '~/styles/mixins' as *;
+
+.grw-tag-skeleton-h3 {
+  @include grw-skeleton-h3;
+  max-width: 120px;
+}
+
+.grw-tag-list-skeleton {
+  height: 90px;
+}

+ 5 - 10
packages/app/src/components/Sidebar/Tag.tsx

@@ -9,6 +9,9 @@ import { useSWRxTagsList } from '~/stores/tag';
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
 
+import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
+import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
+
 
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
@@ -44,22 +47,14 @@ const Tag: FC = () => {
     <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
-        <button
-          type="button"
-          className="btn btn-sm ml-auto grw-btn-reload"
-          onClick={onReload}
-        >
-          <i className="icon icon-reload"></i>
-        </button>
+        <SidebarHeaderReloadButton onClick={() => onReload()}/>
       </div>
 
       <h3 className="my-3">{t('tag_list')}</h3>
 
       { isLoading
         ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
+          <TagListSkeleton />
         )
         : (
           <div data-testid="grw-tags-list">

+ 1 - 1
packages/app/src/components/Skeleton.tsx

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill ?? ''}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
     </div>
   );
 };

+ 21 - 0
packages/app/src/styles/_mixins.scss

@@ -160,3 +160,24 @@
     content: $code;
   }
 }
+
+@mixin grw-skeleton-text($font-size, $line-height) {
+  height: $line-height;
+  padding: (($line-height - $font-size)  / 2) 0;
+}
+/*
+.example {
+  @include grw-skeleton-text($font-size:$size, $line-height:$height);
+  max-width: 100%;
+}
+*/
+
+// values from './bootstrap/override'
+
+@mixin grw-skeleton-h3 {
+  @include grw-skeleton-text(21px, 30px);
+}
+
+@mixin grw-skeleton-h5 {
+  @include grw-skeleton-text(16px, 18px);
+}