Răsfoiți Sursa

Merge pull request #8484 from weseek/feat/wip-page

feat: WIP Page
Yuki Takei 2 ani în urmă
părinte
comite
4f1f905760
39 a modificat fișierele cu 567 adăugiri și 54 ștergeri
  1. 12 0
      apps/app/public/static/locales/en_US/translation.json
  2. 12 0
      apps/app/public/static/locales/ja_JP/translation.json
  3. 12 0
      apps/app/public/static/locales/zh_CN/translation.json
  4. 1 1
      apps/app/src/client/services/create-page/use-create-template-page.ts
  5. 11 0
      apps/app/src/client/services/page-operation.ts
  6. 6 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  7. 3 1
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  8. 3 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  9. 1 1
      apps/app/src/components/Page/PageView.tsx
  10. 2 0
      apps/app/src/components/PageAlert/PageAlerts.tsx
  11. 53 0
      apps/app/src/components/PageAlert/WipPageAlert.tsx
  12. 5 1
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  13. 29 1
      apps/app/src/components/SavePageControls.tsx
  14. 13 4
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  15. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  16. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  17. 8 3
      apps/app/src/components/Sidebar/PageTree/PageTree.tsx
  18. 40 2
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  19. 1 0
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  20. 8 2
      apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx
  21. 0 1
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  22. 54 17
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  23. 3 0
      apps/app/src/components/Sidebar/SidebarContents.module.scss
  24. 2 0
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  25. 11 2
      apps/app/src/components/TreeItem/SimpleItem.tsx
  26. 1 0
      apps/app/src/components/TreeItem/interfaces/index.ts
  27. 2 0
      apps/app/src/interfaces/page.ts
  28. 1 0
      apps/app/src/server/crowi/index.js
  29. 37 0
      apps/app/src/server/models/page.ts
  30. 6 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  31. 7 0
      apps/app/src/server/routes/apiv3/page/index.js
  32. 62 0
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  33. 63 0
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  34. 9 1
      apps/app/src/server/routes/apiv3/pages/index.js
  35. 8 3
      apps/app/src/server/service/config-loader.ts
  36. 55 0
      apps/app/src/server/service/page/index.ts
  37. 8 7
      apps/app/src/stores/page-listing.tsx
  38. 14 0
      apps/app/src/utils/should-create-wip-page.ts
  39. 2 0
      packages/core/src/interfaces/page.ts

+ 12 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -826,5 +826,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "Select page location"
     "select_page_location": "Select page location"
+  },
+  "wip_page": {
+    "save_as_wip": "Save as WIP (Currently drafting)",
+    "success_save_as_wip": "Successfully saved as a WIP page",
+    "fail_save_as_wip": "Failed to save as a WIP page",
+    "alert": "This page is a work in progress",
+    "publish_page": "Publish page",
+    "success_publish_page": "Page has been published",
+    "fail_publish_page": "Failed to publish the Page"
+  },
+  "sidebar_header": {
+    "show_wip_page": "Show WIP"
   }
   }
 }
 }

+ 12 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -859,5 +859,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "ページの場所を選択"
     "select_page_location": "ページの場所を選択"
+  },
+  "wip_page": {
+    "save_as_wip": "WIP (執筆中) として保存",
+    "success_save_as_wip": "WIP ページとして保存しました",
+    "fail_save_as_wip": "WIP ページとして保存できませんでした",
+    "alert": "このページは作業途中です",
+    "publish_page": "WIP を解除",
+    "success_publish_page": "WIP を解除しました",
+    "fail_publish_page": "WIP を解除できませんでした"
+  },
+  "sidebar_header": {
+    "show_wip_page": "WIP を表示"
   }
   }
 }
 }

+ 12 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -829,5 +829,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "选择页面位置"
     "select_page_location": "选择页面位置"
+  },
+  "wip_page": {
+    "save_as_wip": "保存为 WIP(书面)",
+    "success_save_as_wip": "成功保存为 WIP 页面",
+    "fail_save_as_wip": "保存为 WIP 页失败",
+    "alert": "本页面正在制作中",
+    "publish_page": "发布 WIP",
+    "success_publish_page": "WIP 已停用",
+    "fail_publish_page": "无法停用 WIP"
+  },
+  "sidebar_header": {
+    "show_wip_page": "显示 WIP"
   }
   }
 }
 }

+ 1 - 1
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -25,7 +25,7 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
     if (isLoadingPagePath || !isCreatable) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

+ 11 - 0
apps/app/src/client/services/page-operation.ts

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
+import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -159,3 +160,13 @@ export const exist = async(path: string): Promise<PageExistResponse> => {
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   return res.data;
   return res.data;
 };
 };
+
+export const publish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/publish`);
+  return res.data;
+};
+
+export const unpublish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/unpublish`);
+  return res.data;
+};

+ 6 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -20,6 +20,7 @@ const { isTrashPage } = pagePathUtils;
 type Props = {
 type Props = {
   pagePath: string,
   pagePath: string,
   pageId?: string | null,
   pageId?: string | null,
+  isWipPage?: boolean,
   isSingleLineMode?: boolean,
   isSingleLineMode?: boolean,
   isCollapseParents?: boolean,
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
   formerLinkClassName?: string,
@@ -37,7 +38,7 @@ const Separator = (): JSX.Element => {
 
 
 export const PagePathNav: FC<Props> = (props: Props) => {
 export const PagePathNav: FC<Props> = (props: Props) => {
   const {
   const {
-    pageId, pagePath, isSingleLineMode, isCollapseParents,
+    pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
     formerLinkClassName, latterLinkClassName,
     formerLinkClassName, latterLinkClassName,
   } = props;
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
   const dPagePath = new DevidedPagePath(pagePath, false, true);
@@ -94,7 +95,10 @@ export const PagePathNav: FC<Props> = (props: Props) => {
           {latterLink}
           {latterLink}
         </h1>
         </h1>
         { pageId != null && !isNotFound && (
         { pageId != null && !isNotFound && (
-          <div className="mx-2">
+          <div className="d-flex align-items-center ms-2">
+            { isWipPage && (
+              <span className="badge rounded-pill text-bg-secondary ms-1 me-1">WIP</span>
+            )}
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <i className="ti ti-clipboard"></i>
               <i className="ti ti-clipboard"></i>
             </CopyDropdown>
             </CopyDropdown>

+ 3 - 1
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -91,6 +91,7 @@ const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondSta
 type ItemsTreeProps = {
 type ItemsTreeProps = {
   isEnableActions: boolean
   isEnableActions: boolean
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
+  isWipPageShown?: boolean
   targetPath: string
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
   targetAndAncestorsData?: TargetAndAncestors
@@ -103,7 +104,7 @@ type ItemsTreeProps = {
  */
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -281,6 +282,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
           itemNode={initialItemNode}
           itemNode={initialItemNode}
           isOpen
           isOpen
           isEnableActions={isEnableActions}
           isEnableActions={isEnableActions}
+          isWipPageShown={isWipPageShown}
           isReadOnlyUser={isReadOnlyUser}
           isReadOnlyUser={isReadOnlyUser}
           onRenamed={onRenamed}
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}

+ 3 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -7,6 +7,8 @@ import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
 
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
 
 
 import styles from './PageEditorModeManager.module.scss';
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -72,7 +74,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
 
     try {
     try {
       await createAndTransit(
       await createAndTransit(
-        { path },
+        { path, wip: shouldCreateWipPage(path) },
         { shouldCheckPageExists: true },
         { shouldCheckPageExists: true },
       );
       );
     }
     }

+ 1 - 1
apps/app/src/components/Page/PageView.tsx

@@ -103,7 +103,7 @@ export const PageView = (props: Props): JSX.Element => {
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
 
   const headerContents = (
   const headerContents = (
-    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} />
+    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />
   );
   );
 
 
   const sideContents = !isNotFound && !isNotCreatable
   const sideContents = !isNotFound && !isNotCreatable

+ 2 - 0
apps/app/src/components/PageAlert/PageAlerts.tsx

@@ -8,6 +8,7 @@ import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { PageStaleAlert } from './PageStaleAlert';
+import { WipPageAlert } from './WipPageAlert';
 
 
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 // dynamic import because TrashPageAlert uses localStorageMiddleware
 // dynamic import because TrashPageAlert uses localStorageMiddleware
@@ -22,6 +23,7 @@ export const PageAlerts = (): JSX.Element => {
       <div className="col-sm-12">
       <div className="col-sm-12">
         {/* alerts */}
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
         { !isNotFound && <FixPageGrantAlert /> }
+        <WipPageAlert />
         <PageGrantAlert />
         <PageGrantAlert />
         <TrashPageAlert />
         <TrashPageAlert />
         <PageStaleAlert />
         <PageStaleAlert />

+ 53 - 0
apps/app/src/components/PageAlert/WipPageAlert.tsx

@@ -0,0 +1,53 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { publish } from '../../client/services/page-operation';
+
+
+export const WipPageAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
+  const clickPagePublishButton = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await publish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_publish_page'));
+    }
+    catch {
+      toastError(t('wip_page.fail_publish_page'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
+
+  if (!currentPage?.wip) {
+    return <></>;
+  }
+
+  return (
+    <p className="d-flex align-items-center alert alert-secondary py-3 px-4">
+      <span className="material-symbols-outlined me-1 fs-5">info</span>
+      <span>{t('wip_page.alert')}</span>
+      <button
+        type="button"
+        className="btn btn-outline-secondary ms-auto"
+        onClick={clickPagePublishButton}
+      >
+        {t('wip_page.publish_page') }
+      </button>
+    </p>
+  );
+};

+ 5 - 1
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -92,7 +92,11 @@ export const PageTitleHeader: FC<Props> = (props) => {
         </h1>
         </h1>
       </div>
       </div>
 
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''}`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+        { currentPage.wip && (
+          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+        )}
+
         <CopyDropdown
         <CopyDropdown
           pageId={currentPage._id}
           pageId={currentPage._id}
           pagePath={currentPage.path}
           pagePath={currentPage.path}

+ 29 - 1
apps/app/src/components/SavePageControls.tsx

@@ -9,15 +9,19 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useIsAclEnabled,
   useIsEditable, useIsAclEnabled,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useWaitingSaveProcessing } from '~/stores/editor';
 import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { unpublish } from '../client/services/page-operation';
+
 import { GrantSelector } from './SavePageControls/GrantSelector';
 import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
 
 
@@ -41,6 +45,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
 
@@ -58,6 +63,25 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
     globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
   }, [slackChannels]);
   }, [slackChannels]);
 
 
+  const clickUnpublishButtonHandler = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await unpublish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_save_as_wip'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('wip_page.fail_save_as_wip'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
   if (isEditable == null || isAclEnabled == null || grantData == null) {
     return null;
     return null;
@@ -72,6 +96,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+  const labelUnpublishPage = t('wip_page.save_as_wip');
 
 
   return (
   return (
     <div className="d-flex align-items-center flex-nowrap">
     <div className="d-flex align-items-center flex-nowrap">
@@ -108,6 +133,9 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}
             {labelOverwriteScopes}
           </DropdownItem>
           </DropdownItem>
+          <DropdownItem onClick={clickUnpublishButtonHandler}>
+            {labelUnpublishPage}
+          </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
       </UncontrolledButtonDropdown>
       </UncontrolledButtonDropdown>
 
 

+ 13 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,16 +1,25 @@
-import Link from 'next/link';
+import { useCallback } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+
 export const SidebarNotFound = (): JSX.Element => {
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { createAndTransit } = useCreatePageAndTransit();
+
+  const clickCreateButtonHandler = useCallback(async() => {
+    createAndTransit({ path: '/Sidebar', wip: false });
+  }, [createAndTransit]);
+
   return (
   return (
-    <div className="grw-sidebar-content-header h5 text-center py-3">
-      <Link href="/Sidebar#edit">
+    <div>
+      <button type="button" className="btn btn-lg btn-link" onClick={clickCreateButtonHandler}>
         <span className="material-symbols-outlined">edit_note</span>
         <span className="material-symbols-outlined">edit_note</span>
         {/* eslint-disable-next-line react/no-danger */}
         {/* eslint-disable-next-line react/no-danger */}
         <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
         <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
-      </Link>
+      </button>
     </div>
     </div>
   );
   );
 };
 };

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -18,7 +18,7 @@ export const useCreateNewPage: UseCreateNewPage = () => {
     if (isLoadingPagePath) return;
     if (isLoadingPagePath) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { parentPath: currentPagePath, optionalParentPath: '/' },
+      { parentPath: currentPagePath, optionalParentPath: '/', wip: true },
     );
     );
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
 
 

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -32,7 +32,7 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
     if (!isCreatable || todaysPath == null) return;
     if (!isCreatable || todaysPath == null) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { path: todaysPath },
+      { path: todaysPath, wip: true },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
   }, [createAndTransit, isCreatable, todaysPath]);
   }, [createAndTransit, isCreatable, todaysPath]);

+ 8 - 3
apps/app/src/components/Sidebar/PageTree/PageTree.tsx

@@ -1,4 +1,4 @@
-import { Suspense } from 'react';
+import { Suspense, useState } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -16,17 +16,22 @@ const PageTreeContent = dynamic(
 export const PageTree = (): JSX.Element => {
 export const PageTree = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const [isWipPageShown, setIsWipPageShown] = useState(true);
+
   return (
   return (
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
         <h4 className="mb-0">{t('Page Tree')}</h4>
         <h4 className="mb-0">{t('Page Tree')}</h4>
         <Suspense>
         <Suspense>
-          <PageTreeHeader />
+          <PageTreeHeader
+            isWipPageShown={isWipPageShown}
+            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+          />
         </Suspense>
         </Suspense>
       </div>
       </div>
 
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <PageTreeContent />
+        <PageTreeContent isWipPageShown={isWipPageShown} />
       </Suspense>
       </Suspense>
     </div>
     </div>
   );
   );

+ 40 - 2
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,6 +1,9 @@
 import React, { memo, useCallback } from 'react';
 import React, { memo, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import {
+  UncontrolledButtonDropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
 
 
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
@@ -12,8 +15,14 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 
+type HeaderProps = {
+  isWipPageShown: boolean,
+  onWipPageShownChange?: () => void
+}
+
+export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
+  const { t } = useTranslation();
 
 
-export const PageTreeHeader = memo(() => {
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
 
 
@@ -25,6 +34,29 @@ export const PageTreeHeader = memo(() => {
   return (
   return (
     <>
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
       <SidebarHeaderReloadButton onClick={() => mutate()} />
+
+      <UncontrolledButtonDropdown className="me-1">
+        <DropdownToggle color="transparent" className="p-0 border-0">
+          <span className="material-symbols-outlined">more_horiz</span>
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
+          <DropdownItem onClick={onWipPageShownChange} className="">
+            <div className="form-check form-switch">
+              <input
+                id="wipPageVisibility"
+                className="form-check-input"
+                type="checkbox"
+                checked={isWipPageShown}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="wipPageVisibility">
+                {t('sidebar_header.show_wip_page')}
+              </label>
+            </div>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledButtonDropdown>
     </>
     </>
   );
   );
 });
 });
@@ -44,7 +76,12 @@ const PageTreeUnavailable = () => {
   );
   );
 };
 };
 
 
-export const PageTreeContent = memo(() => {
+type PageTreeContentProps = {
+  isWipPageShown: boolean,
+}
+
+export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) => {
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
@@ -73,6 +110,7 @@ export const PageTreeContent = memo(() => {
       <ItemsTree
       <ItemsTree
         isEnableActions={!isGuestUser}
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
         isReadOnlyUser={!!isReadOnlyUser}
+        isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetPathOrId={targetPathOrId}
         targetAndAncestorsData={targetAndAncestorsData}
         targetAndAncestorsData={targetAndAncestorsData}

+ 1 - 0
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -178,6 +178,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       isOpen={isOpen}
       isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
       isReadOnlyUser={props.isReadOnlyUser}
+      isWipPageShown={props.isWipPageShown}
       onClick={itemSelectedHandler}
       onClick={itemSelectedHandler}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}

+ 8 - 2
apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -16,18 +16,24 @@ export const RecentChanges = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [isSmall, setIsSmall] = useState(false);
   const [isSmall, setIsSmall] = useState(false);
+  const [isWipPageShown, setIsWipPageShown] = useState(true);
 
 
   return (
   return (
     <div className="px-3" data-testid="grw-recent-changes">
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-4 d-flex">
       <div className="grw-sidebar-content-header py-4 d-flex">
         <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
         <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
         <Suspense>
         <Suspense>
-          <RecentChangesHeader isSmall={isSmall} onSizeChange={setIsSmall} />
+          <RecentChangesHeader
+            isSmall={isSmall}
+            onSizeChange={setIsSmall}
+            isWipPageShown={isWipPageShown}
+            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+          />
         </Suspense>
         </Suspense>
       </div>
       </div>
 
 
       <Suspense fallback={<RecentChangesContentSkeleton />}>
       <Suspense fallback={<RecentChangesContentSkeleton />}>
-        <RecentChangesContent isSmall={isSmall} />
+        <RecentChangesContent isWipPageShown={isWipPageShown} isSmall={isSmall} />
       </Suspense>
       </Suspense>
     </div>
     </div>
   );
   );

+ 0 - 1
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -1,7 +1,6 @@
 @use '~/styles/mixins' as *;
 @use '~/styles/mixins' as *;
 
 
 .grw-recent-changes-resize-button :global {
 .grw-recent-changes-resize-button :global {
-  font-size: 12px;
   line-height: normal;
   line-height: normal;
   transform: translateY(-2px);
   transform: translateY(-2px);
 
 

+ 54 - 17
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -7,6 +7,10 @@ import {
 } from '@growi/core';
 } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'react-i18next';
+import {
+  DropdownItem, DropdownMenu, DropdownToggle, UncontrolledButtonDropdown,
+} from 'reactstrap';
 
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -116,8 +120,11 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
               { !dPagePath.isRoot && <FormerLink /> }
               { !dPagePath.isRoot && <FormerLink /> }
             </div>
             </div>
 
 
-            <h6 className={`col-12 ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
+            <h6 className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
               <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
               <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+              { page.wip && (
+                <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
+              ) }
               {locked}
               {locked}
             </h6>
             </h6>
 
 
@@ -143,12 +150,17 @@ PageItem.displayName = 'PageItem';
 type HeaderProps = {
 type HeaderProps = {
   isSmall: boolean,
   isSmall: boolean,
   onSizeChange: (isSmall: boolean) => void,
   onSizeChange: (isSmall: boolean) => void,
+  isWipPageShown: boolean,
+  onWipPageShownChange: () => void,
 }
 }
 
 
 const PER_PAGE = 20;
 const PER_PAGE = 20;
-export const RecentChangesHeader = ({ isSmall, onSizeChange }: HeaderProps): JSX.Element => {
+export const RecentChangesHeader = ({
+  isSmall, onSizeChange, isWipPageShown, onWipPageShownChange,
+}: HeaderProps): JSX.Element => {
+  const { t } = useTranslation();
 
 
-  const { mutate } = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
+  const { mutate } = useSWRINFxRecentlyUpdated(PER_PAGE, isWipPageShown, { suspense: true });
 
 
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
@@ -169,28 +181,53 @@ export const RecentChangesHeader = ({ isSmall, onSizeChange }: HeaderProps): JSX
   return (
   return (
     <>
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
       <SidebarHeaderReloadButton onClick={() => mutate()} />
-      <div className="d-flex align-items-center">
-        <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} form-check form-switch ms-1`}>
-          <input
-            id="recentChangesResize"
-            className="form-check-input"
-            type="checkbox"
-            checked={isSmall}
-            onChange={changeSizeHandler}
-          />
-          <label className="form-label form-check-label" htmlFor="recentChangesResize" />
-        </div>
-      </div>
+
+      <UncontrolledButtonDropdown className="me-1">
+        <DropdownToggle color="transparent" className="p-0 border-0">
+          <span className="material-symbols-outlined">more_horiz</span>
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
+          <DropdownItem onClick={changeSizeHandler}>
+            <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch`}>
+              <input
+                id="recentChangesResize"
+                className="form-check-input"
+                type="checkbox"
+                checked={isSmall}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="recentChangesResize" />
+            </div>
+          </DropdownItem>
+
+          <DropdownItem onClick={onWipPageShownChange}>
+            <div className="form-check form-switch">
+              <input
+                id="wipPageVisibility"
+                className="form-check-input"
+                type="checkbox"
+                checked={isWipPageShown}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="wipPageVisibility">
+                {t('sidebar_header.show_wip_page')}
+              </label>
+            </div>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledButtonDropdown>
     </>
     </>
   );
   );
 };
 };
 
 
 type ContentProps = {
 type ContentProps = {
   isSmall: boolean,
   isSmall: boolean,
+  isWipPageShown: boolean,
 }
 }
 
 
-export const RecentChangesContent = ({ isSmall }: ContentProps): JSX.Element => {
-  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
+export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps): JSX.Element => {
+  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, isWipPageShown, { suspense: true });
   const { data } = swrInifinitexRecentlyUpdated;
   const { data } = swrInifinitexRecentlyUpdated;
 
 
   const { pushState } = useKeywordManager();
   const { pushState } = useKeywordManager();

+ 3 - 0
apps/app/src/components/Sidebar/SidebarContents.module.scss

@@ -11,4 +11,7 @@
     --bs-list-group-bg: transparent;
     --bs-list-group-bg: transparent;
   }
   }
 
 
+  .wip-page-badge {
+    --bs-badge-font-size: 0.5rem;
+  }
 }
 }

+ 2 - 0
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -4,6 +4,7 @@ import { createPage } from '~/client/services/page-operation';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
 
+import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
 import type { TreeItemToolProps } from '../interfaces';
 import type { TreeItemToolProps } from '../interfaces';
 
 
 import { NewPageCreateButton } from './NewPageCreateButton';
 import { NewPageCreateButton } from './NewPageCreateButton';
@@ -73,6 +74,7 @@ export const useNewPageInput = (): UseNewPageInput => {
         // keep grant info undefined to inherit from parent
         // keep grant info undefined to inherit from parent
         grant: undefined,
         grant: undefined,
         grantUserGroupIds: undefined,
         grantUserGroupIds: undefined,
+        wip: shouldCreateWipPage(newPagePath),
       });
       });
 
 
       mutateChildren();
       mutateChildren();

+ 11 - 2
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -58,7 +58,12 @@ const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
       )}
       )}
       {page != null && page.path != null && page._id != null && (
       {page != null && page.path != null && page._id != null && (
         <div className="grw-pagetree-title-anchor flex-grow-1">
         <div className="grw-pagetree-title-anchor flex-grow-1">
-          <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
+          <div className="d-flex align-items-center">
+            <span className={`text-truncate ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</span>
+            { page.wip && (
+              <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
+            )}
+          </div>
         </div>
         </div>
       )}
       )}
     </div>
     </div>
@@ -90,7 +95,7 @@ type SimpleItemProps = TreeItemProps & {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
   const {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass, mainClassName,
     itemRef, itemClass, mainClassName,
   } = props;
   } = props;
 
 
@@ -165,6 +170,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     isEnableActions,
     isEnableActions,
     isReadOnlyUser,
     isReadOnlyUser,
     isOpen: false,
     isOpen: false,
+    isWipPageShown,
     targetPathOrId,
     targetPathOrId,
     onRenamed,
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,
@@ -178,6 +184,9 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
 
   const CustomNextComponents = props.customNextComponents;
   const CustomNextComponents = props.customNextComponents;
 
 
+  if (!isWipPageShown && page.wip) {
+    return <></>;
+  }
 
 
   return (
   return (
     <div
     <div

+ 1 - 0
apps/app/src/components/TreeItem/interfaces/index.ts

@@ -24,6 +24,7 @@ export type TreeItemToolProps = TreeItemBaseProps;
 export type TreeItemProps = TreeItemBaseProps & {
 export type TreeItemProps = TreeItemBaseProps & {
   targetPathOrId?: Nullable<string>,
   targetPathOrId?: Nullable<string>,
   isOpen?: boolean,
   isOpen?: boolean,
+  isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   mainClassName?: string,
   mainClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,

+ 2 - 0
apps/app/src/interfaces/page.ts

@@ -43,4 +43,6 @@ export type IOptionsForCreate = {
   grant?: PageGrant,
   grant?: PageGrant,
   grantUserGroupIds?: IGrantedGroup[],
   grantUserGroupIds?: IGrantedGroup[],
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
+
+  wip?: boolean,
 };
 };

+ 1 - 0
apps/app/src/server/crowi/index.js

@@ -725,6 +725,7 @@ Crowi.prototype.setupPageService = async function() {
   // initialize after pageGrantService since pageService uses pageGrantService in constructor
   // initialize after pageGrantService since pageService uses pageGrantService in constructor
   if (this.pageService == null) {
   if (this.pageService == null) {
     this.pageService = new PageService(this);
     this.pageService = new PageService(this);
+    await this.pageService.createTtlIndex();
   }
   }
   if (this.pageOperationService == null) {
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
     this.pageOperationService = new PageOperationService(this);

+ 37 - 0
apps/app/src/server/models/page.ts

@@ -139,6 +139,8 @@ const schema = new Schema<PageDocument, PageModel>({
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   commentCount: { type: Number, default: 0 },
   expandContentWidth: { type: Boolean },
   expandContentWidth: { type: Boolean },
+  wip: { type: Boolean },
+  ttlTimestamp: { type: Date, index: true },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
   deletedAt: { type: Date },
@@ -202,6 +204,18 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToExcludeWipPage(): PageQueryBuilder {
+    this.query = this.query
+      .and({
+        $or: [
+          { wip: undefined },
+          { wip: false },
+        ],
+      });
+
+    return this;
+  }
+
   /**
   /**
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * If top page, return without doing anything.
    * If top page, return without doing anything.
@@ -653,10 +667,15 @@ schema.statics.findRecentUpdatedPages = async function(
 
 
   const baseQuery = this.find({});
   const baseQuery = this.find({});
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
   if (!options.includeTrashed) {
   if (!options.includeTrashed) {
     queryBuilder.addConditionToExcludeTrashed();
     queryBuilder.addConditionToExcludeTrashed();
   }
   }
 
 
+  if (!options.includeWipPage) {
+    queryBuilder.addConditionToExcludeWipPage();
+  }
+
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
   await queryBuilder.addViewerCondition(user);
   await queryBuilder.addViewerCondition(user);
@@ -1052,6 +1071,24 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
   await this.save();
   await this.save();
 };
 };
 
 
+schema.methods.publish = function() {
+  this.wip = undefined;
+  this.ttlTimestamp = undefined;
+};
+
+schema.methods.unpublish = function() {
+  this.wip = true;
+  this.ttlTimestamp = undefined;
+};
+
+schema.methods.makeWip = function(disableTtl: boolean) {
+  this.wip = true;
+
+  if (!disableTtl) {
+    this.ttlTimestamp = new Date();
+  }
+};
+
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */

+ 6 - 2
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -111,6 +111,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('wip').optional().isBoolean().withMessage('wip must be boolean'),
   ];
   ];
 
 
 
 
@@ -220,8 +221,11 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
 
 
       let createdPage;
       let createdPage;
       try {
       try {
-        const { grant, grantUserGroupIds, overwriteScopesOfDescendants } = req.body;
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+        const {
+          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip,
+        } = req.body;
+
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip };
         if (grant != null) {
         if (grant != null) {
           options.grant = grant;
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;
           options.grantUserGroupIds = grantUserGroupIds;

+ 7 - 0
apps/app/src/server/routes/apiv3/page/index.js

@@ -22,6 +22,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
+import { publishPageHandlersFactory } from './publish-page';
+import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 import { updatePageHandlersFactory } from './update-page';
 
 
 
 
@@ -920,5 +922,10 @@ module.exports = (crowi) => {
       }
       }
     });
     });
 
 
+
+  router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
+
+  router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
+
   return router;
   return router;
 };
 };

+ 62 - 0
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -0,0 +1,62 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
+
+
+type ReqParams = {
+  pageId: string,
+}
+
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      try {
+        const page = await Page.findById(pageId);
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+        }
+
+        page.publish();
+        const updatedPage = await page.save();
+        return res.apiv3(updatedPage);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 63 - 0
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -0,0 +1,63 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
+
+
+type ReqParams = {
+  pageId: string,
+}
+
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      try {
+        const page = await Page.findById(pageId);
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+        }
+
+        page.unpublish();
+        const updatedPage = await page.save();
+
+        return res.apiv3(updatedPage);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 9 - 1
apps/app/src/server/routes/apiv3/pages/index.js

@@ -160,6 +160,11 @@ module.exports = (crowi) => {
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const validator = {
   const validator = {
+    recent: [
+      query('limit').optional().isInt().withMessage('limit must be integer'),
+      query('offset').optional().isInt().withMessage('offset must be integer'),
+      query('includeWipPage').optional().isBoolean().withMessage('includeWipPage must be boolean'),
+    ],
     renamePage: [
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -216,12 +221,15 @@ module.exports = (crowi) => {
    *            description: Return pages recently updated
    *            description: Return pages recently updated
    *
    *
    */
    */
-  router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
     const limit = parseInt(req.query.limit) || 20;
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
+    const includeWipPage = req.query.includeWipPage === 'true'; // Need validation using express-validator
+
     const queryOptions = {
     const queryOptions = {
       offset,
       offset,
       limit,
       limit,
+      includeWipPage,
       includeTrashed: false,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       sort: 'updatedAt',

+ 8 - 3
apps/app/src/server/service/config-loader.ts

@@ -4,9 +4,8 @@ import { parseISO } from 'date-fns';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import ConfigModel, {
-  Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
-} from '../models/config';
+import type { Config } from '../models/config';
+import ConfigModel, { defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs } from '../models/config';
 
 
 
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 const logger = loggerFactory('growi:service:ConfigLoader');
@@ -712,6 +711,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     type: ValueType.NUMBER,
     default: 30000,
     default: 30000,
   },
   },
+  WIP_PAGE_EXPIRATION_SECONDS: {
+    ns: 'crowi',
+    key: 'app:wipPageExpirationSeconds',
+    type: ValueType.NUMBER,
+    default: 172800, // 2 days
+  },
 };
 };
 
 
 
 

+ 55 - 0
apps/app/src/server/service/page/index.ts

@@ -584,6 +584,8 @@ class PageService implements IPageService {
 
 
       this.activityEvent.emit('updated', activity, page, preNotify);
       this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
+
+    this.disableAncestorPagesTtl(newPagePath);
     return renamedPage;
     return renamedPage;
   }
   }
 
 
@@ -3811,6 +3813,13 @@ class PageService implements IPageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
       page.parent = parent._id;
     }
     }
+
+    // Make WIP
+    if (options.wip) {
+      const hasChildren = await Page.exists({ parent: page._id });
+      page.makeWip(hasChildren != null); // disableTtl = hasChildren != null
+    }
+
     // Save
     // Save
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
@@ -3849,6 +3858,8 @@ class PageService implements IPageService {
    * Used to run sub operation in create method
    * Used to run sub operation in create method
    */
    */
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
+    await this.disableAncestorPagesTtl(page.path);
+
     // Update descendantCount
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
 
 
@@ -3933,6 +3944,18 @@ class PageService implements IPageService {
     return this.canProcessCreate(path, grantData, false);
     return this.canProcessCreate(path, grantData, false);
   }
   }
 
 
+  private async disableAncestorPagesTtl(path: string): Promise<void> {
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    const ancestorPaths = collectAncestorPaths(path);
+    const ancestorPageIds = await Page.aggregate([
+      { $match: { path: { $in: ancestorPaths, $nin: ['/'] }, isEmpty: false } },
+      { $project: { _id: 1 } },
+    ]);
+
+    await Page.updateMany({ _id: { $in: ancestorPageIds } }, { $unset: { ttlTimestamp: true } });
+  }
+
   /**
   /**
    * @private
    * @private
    * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
    * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
@@ -4123,6 +4146,10 @@ class PageService implements IPageService {
     const clonedPageData = Page.hydrate(pageData.toObject());
     const clonedPageData = Page.hydrate(pageData.toObject());
     const newPageData = pageData;
     const newPageData = pageData;
 
 
+    // If updated at least once, publish
+    pageData.publish();
+
+
     // use the previous data if absent
     // use the previous data if absent
     const grant = options.grant ?? clonedPageData.grant;
     const grant = options.grant ?? clonedPageData.grant;
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
@@ -4403,6 +4430,34 @@ class PageService implements IPageService {
     });
     });
   }
   }
 
 
+  async createTtlIndex(): Promise<void> {
+    const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
+    const collection = mongoose.connection.collection('pages');
+
+    try {
+      const targetField = 'ttlTimestamp_1';
+
+      const indexes = await collection.indexes();
+      const foundTargetField = indexes.find(i => i.name === targetField);
+
+      const isNotSpec = foundTargetField?.expireAfterSeconds == null || foundTargetField?.expireAfterSeconds !== wipPageExpirationSeconds;
+      const shoudDropIndex = foundTargetField != null && isNotSpec;
+      const shoudCreateIndex = foundTargetField == null || shoudDropIndex;
+
+      if (shoudDropIndex) {
+        await collection.dropIndex(targetField);
+      }
+
+      if (shoudCreateIndex) {
+        await collection.createIndex({ ttlTimestamp: 1 }, { expireAfterSeconds: wipPageExpirationSeconds });
+      }
+    }
+    catch (err) {
+      logger.error('Failed to create TTL Index', err);
+      throw err;
+    }
+  }
+
 }
 }
 
 
 export default PageService;
 export default PageService;

+ 8 - 7
apps/app/src/stores/page-listing.tsx

@@ -8,12 +8,13 @@ import useSWR, {
   mutate, type SWRConfiguration, type SWRResponse, type Arguments,
   mutate, type SWRConfiguration, type SWRResponse, type Arguments,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
 
 
-import { IPagingResult } from '~/interfaces/paging-result';
+import type { IPagingResult } from '~/interfaces/paging-result';
 
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
-import {
+import type {
   AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
   AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
 } from '../interfaces/page-listing-results';
 } from '../interfaces/page-listing-results';
 
 
@@ -32,19 +33,19 @@ type RecentApiResult = {
   totalCount: number,
   totalCount: number,
   offset: number,
   offset: number,
 }
 }
-export const useSWRINFxRecentlyUpdated = (limit: number, config?: SWRConfiguration) : SWRInfiniteResponse<RecentApiResult, Error> => {
+export const useSWRINFxRecentlyUpdated = (limit: number, includeWipPage?: boolean, config?: SWRConfiguration) : SWRInfiniteResponse<RecentApiResult, Error> => {
   return useSWRInfinite(
   return useSWRInfinite(
     (pageIndex, previousPageData) => {
     (pageIndex, previousPageData) => {
       if (previousPageData != null && previousPageData.pages.length === 0) return null;
       if (previousPageData != null && previousPageData.pages.length === 0) return null;
 
 
       if (pageIndex === 0 || previousPageData == null) {
       if (pageIndex === 0 || previousPageData == null) {
-        return ['/pages/recent', undefined, limit];
+        return ['/pages/recent', undefined, limit, includeWipPage];
       }
       }
 
 
       const offset = previousPageData.offset + limit;
       const offset = previousPageData.offset + limit;
-      return ['/pages/recent', offset, limit];
+      return ['/pages/recent', offset, limit, includeWipPage];
     },
     },
-    ([endpoint, offset, limit]) => apiv3Get<RecentApiResult>(endpoint, { offset, limit }).then(response => response.data),
+    ([endpoint, offset, limit, includeWipPage]) => apiv3Get<RecentApiResult>(endpoint, { offset, limit, includeWipPage }).then(response => response.data),
     {
     {
       ...config,
       ...config,
       revalidateFirstPage: false,
       revalidateFirstPage: false,

+ 14 - 0
apps/app/src/utils/should-create-wip-page.ts

@@ -0,0 +1,14 @@
+import { checkTemplatePath } from '@growi/core/dist/utils/template-checker';
+
+/**
+ * Returns Whether to create pages with the wip flag
+ * @param {string|undefined} path
+ * @returns {boolean}
+ */
+export const shouldCreateWipPage = (path?: string): boolean => {
+  if (path == null) {
+    return true;
+  }
+
+  return !(checkTemplatePath(path) || path === '/Sidebar');
+};

+ 2 - 0
packages/core/src/interfaces/page.ts

@@ -39,6 +39,8 @@ export type IPage = {
   latestRevision?: Ref<IRevision>,
   latestRevision?: Ref<IRevision>,
   latestRevisionBodyLength?: number,
   latestRevisionBodyLength?: number,
   expandContentWidth?: boolean,
   expandContentWidth?: boolean,
+  wip?: boolean,
+  ttlTimestamp?: Date
 }
 }
 
 
 export type IPagePopulatedToList = Omit<IPageHasId, 'lastUpdateUser'> & {
 export type IPagePopulatedToList = Omit<IPageHasId, 'lastUpdateUser'> & {