Browse Source

Merge branch 'dev/7.0.x' into feat/139973-implement-function-to-select-parent-page-from-modal

kosei-n 2 years ago
parent
commit
376786402c
64 changed files with 1020 additions and 929 deletions
  1. 1 2
      apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js
  2. 36 2
      apps/app/src/client/services/use-create-page-and-transit.tsx
  3. 1 1
      apps/app/src/client/services/use-on-template-button-clicked.ts
  4. 24 12
      apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx
  5. 2 3
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  6. 4 0
      apps/app/src/components/PageComment/CommentEditor.module.scss
  7. 2 3
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  8. 10 10
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  9. 12 12
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  10. 3 2
      apps/app/src/components/PageHistory/RevisionDiff.tsx
  11. 11 14
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  12. 3 2
      apps/app/src/components/PageTags/PageTags.tsx
  13. 1 1
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  14. 1 1
      apps/app/src/components/Skeleton.tsx
  15. 1 1
      apps/app/src/interfaces/editor-settings.ts
  16. 1 0
      apps/app/src/interfaces/page-tag-relation.ts
  17. 6 2
      apps/app/src/server/crowi/index.js
  18. 1 1
      apps/app/src/server/events/activity.ts
  19. 6 3
      apps/app/src/server/models/GlobalNotificationSetting.ts
  20. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  21. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  22. 19 0
      apps/app/src/server/models/GlobalNotificationSetting/consts.ts
  23. 5 1
      apps/app/src/server/models/index.ts
  24. 0 180
      apps/app/src/server/models/page-tag-relation.js
  25. 208 0
      apps/app/src/server/models/page-tag-relation.ts
  26. 7 4
      apps/app/src/server/models/page.ts
  27. 3 4
      apps/app/src/server/models/tag.ts
  28. 1 1
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  29. 7 6
      apps/app/src/server/routes/apiv3/notification-setting.js
  30. 4 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  31. 234 0
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  32. 8 8
      apps/app/src/server/routes/apiv3/page/index.js
  33. 19 194
      apps/app/src/server/routes/apiv3/pages/index.js
  34. 2 1
      apps/app/src/server/routes/comment.js
  35. 0 2
      apps/app/src/server/routes/index.js
  36. 5 243
      apps/app/src/server/routes/page.js
  37. 3 3
      apps/app/src/server/routes/tag.js
  38. 10 10
      apps/app/src/server/service/global-notification/global-notification-mail.js
  39. 15 17
      apps/app/src/server/service/global-notification/global-notification-slack.js
  40. 2 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  41. 6 25
      apps/app/src/server/service/page/index.ts
  42. 11 1
      apps/app/src/server/service/page/page-service.ts
  43. 6 7
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  44. 2 3
      apps/app/src/server/service/user-notification/index.ts
  45. 3 32
      apps/app/src/stores/modal.tsx
  46. 10 3
      apps/app/src/stores/page.tsx
  47. 0 2
      apps/app/test/integration/models/v5.page.test.js
  48. 1 2
      apps/app/test/integration/service/page.test.js
  49. 65 8
      apps/app/test/integration/service/v5.non-public-page.test.ts
  50. 0 2
      apps/app/test/integration/service/v5.page.test.ts
  51. 10 11
      apps/app/test/integration/service/v5.public-page.test.ts
  52. 3 3
      apps/app/test/integration/setup-crowi.ts
  53. 1 0
      packages/core/scss/bootstrap/_variables.scss
  54. 17 13
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  55. 2 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx
  56. 6 8
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  57. 39 0
      packages/editor/src/components/CodeMirrorEditor/Toolbar/LinkEditButton.tsx
  58. 1 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  59. 2 4
      packages/editor/src/components/playground/PlaygroundController.tsx
  60. 38 22
      packages/editor/src/services/editor-theme/index.ts
  61. 27 23
      packages/editor/src/services/link-util/Linker.ts
  62. 41 0
      packages/editor/src/services/link-util/markdown-link-util.ts
  63. 30 0
      packages/editor/src/stores/use-link-edit-modal.ts
  64. 11 0
      packages/override/_dropdown.scss

+ 1 - 2
apps/app/src/components/PageEditor/MarkdownLinkUtil.js → apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js

@@ -1,5 +1,4 @@
-import Linker from '~/client/models/Linker';
-
+import Linker from '@growi/editor/src/services/link-util/Linker';
 /**
  * Utility for markdown link
  */

+ 36 - 2
apps/app/src/components/Navbar/hooks.tsx → apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import { useRouter } from 'next/router';
 
 import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound } from '~/stores/page';
+import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -46,14 +46,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
+  const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { mutate: mutateEditorMode } = useEditorMode();
 
+  // const {
+  //   path: currentPagePath,
+  //   grant: currentPageGrant,
+  //   grantedGroups: currentPageGrantedGroups,
+  // } = currentPage ?? {};
+
   return useCallback(async(pagePath, opts = {}) => {
+    if (isLoading) {
+      return;
+    }
+
     const {
       onCreationStart, onCreated, onAborted, onError, onTerminated,
     } = opts;
 
     if (isNotFound == null || !isNotFound || pagePath == null) {
+      mutateEditorMode(EditorMode.Editor);
+
       onAborted?.();
       onTerminated?.();
       return;
@@ -62,6 +75,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
     try {
       onCreationStart?.();
 
+      /**
+       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
+       * since the new page path is not generated on the client side.
+       * need shouldGeneratePath flag.
+       */
+      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
+      // const parentPath = shouldCreateUnderRoot
+      //   ? '/'
+      //   : currentPagePath;
+
+      // const params = {
+      //   isSlackEnabled: false,
+      //   slackChannels: '',
+      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
+      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
+      //   shouldGeneratePath: true,
+      // };
+
+      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
+      // const response = await createPage(parentPath, '', params);
+
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
@@ -85,5 +119,5 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
       onTerminated?.();
     }
 
-  }, [isNotFound, mutateEditorMode, router]);
+  }, [isLoading, isNotFound, mutateEditorMode, router]);
 };

+ 1 - 1
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -4,7 +4,7 @@ import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 export const useOnTemplateButtonClicked = (
     currentPagePath?: string,

+ 24 - 12
apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx

@@ -1,12 +1,25 @@
+import { useMemo } from 'react';
+
+import Link from 'next/link';
 import {
   DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown,
 } from 'reactstrap';
 
-import LinkedPagePath from '~/models/linked-page-path';
-
+import type LinkedPagePath from '~/models/linked-page-path';
 
 import styles from './CollapsedParentsDropdown.module.scss';
 
+const getAncestorPathAndPathNames = (linkedPagePath: LinkedPagePath) => {
+  const pathAndPathName: Array<{ path: string, pathName: string }> = [];
+  let currentLinkedPagePath = linkedPagePath;
+
+  while (currentLinkedPagePath.parent != null) {
+    pathAndPathName.unshift({ path: currentLinkedPagePath.path, pathName: currentLinkedPagePath.pathName });
+    currentLinkedPagePath = currentLinkedPagePath.parent;
+  }
+
+  return pathAndPathName;
+};
 
 type Props = {
   linkedPagePath: LinkedPagePath,
@@ -15,20 +28,19 @@ type Props = {
 export const CollapsedParentsDropdown = (props: Props): JSX.Element => {
   const { linkedPagePath } = props;
 
+  const ancestorPathAndPathNames = useMemo(() => getAncestorPathAndPathNames(linkedPagePath), [linkedPagePath]);
+
   return (
     <UncontrolledDropdown className="d-inline-block">
       <DropdownToggle color="transparent">...</DropdownToggle>
       <DropdownMenu className={`dropdown-menu ${styles['collapsed-parents-dropdown-menu']}`} container="body">
-        {/* TODO: generate DropdownItems */}
-        <DropdownItem>
-          <a role="menuitem">foo</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">bar</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">baz</a>
-        </DropdownItem>
+        {ancestorPathAndPathNames.map(data => (
+          <DropdownItem key={data.path}>
+            <Link href={data.path} legacyBehavior>
+              <a role="menuitem">{data.pathName}</a>
+            </Link>
+          </DropdownItem>
+        ))}
       </DropdownMenu>
     </UncontrolledDropdown>
   );

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

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { toastError } from '~/client/util/toastr';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from './hooks';
+import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -76,12 +76,11 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
       path,
       {
         onCreationStart: () => { setIsCreating(true) },
-        onAborted: () => { mutateEditorMode(EditorMode.Editor) },
         onError: () => { toastError(t('toaster.create_failed', { target: path })) },
         onTerminated: () => { setIsCreating(false) },
       },
     );
-  }, [createPageAndTransit, path, mutateEditorMode, t]);
+  }, [createPageAndTransit, path, t]);
 
   return (
     <>

+ 4 - 0
apps/app/src/components/PageComment/CommentEditor.module.scss

@@ -4,6 +4,10 @@
 
 // display cheatsheet for comment form only
 .comment-editor-styles :global {
+  .cm-editor {
+    height: 300px !important;
+  }
+
   .comment-form {
     position: relative;
     margin-top: 1em;

+ 2 - 3
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
 
 import path from 'path';
 
+import Linker from '@growi/editor/src/services/link-util/Linker';
+import { useLinkEditModal } from '@growi/editor/src/stores/use-link-edit-modal';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -13,10 +15,7 @@ import {
 } from 'reactstrap';
 import validator from 'validator';
 
-
-import Linker from '~/client/models/Linker';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { useLinkEditModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';

+ 10 - 10
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -37,26 +37,26 @@ export const PagePathHeader: FC<Props> = (props) => {
 
   const { t } = useTranslation();
 
-  const onRenameFinish = () => {
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
-  };
+  }, []);
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
-  };
+  }, []);
 
-  const onInputChange = (inputText: string) => {
+  const onInputChange = useCallback((inputText: string) => {
     setEditedPagePath(inputText);
-  };
+  }, []);
 
-  const onPressEnter = () => {
+  const onPressEnter = useCallback(() => {
     pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
-  };
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const onPressEscape = () => {
+  const onPressEscape = useCallback(() => {
     setEditedPagePath(currentPagePath);
     setRenameInputShown(false);
-  };
+  }, [currentPagePath]);
 
   const onClickEditButton = useCallback(() => {
     if (isRenameInputShown) {

+ 12 - 12
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -30,13 +30,13 @@ export const PageTitleHeader: FC<Props> = (props) => {
 
   const editedPageTitle = nodePath.basename(editedPagePath);
 
-  const onRenameFinish = () => {
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
-  };
+  }, []);
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
-  };
+  }, []);
 
   const onInputChange = useCallback((inputText: string) => {
     const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
@@ -45,23 +45,23 @@ export const PageTitleHeader: FC<Props> = (props) => {
     setEditedPagePath(newPagePath);
   }, [currentPage?.path, setEditedPagePath]);
 
-  const onPressEnter = () => {
+  const onPressEnter = useCallback(() => {
     pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
-  };
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const onPressEscape = () => {
+  const onPressEscape = useCallback(() => {
     setEditedPagePath(currentPagePath);
     setRenameInputShown(false);
-  };
+  }, [currentPagePath]);
 
-  const onClickButton = () => {
+  const onClickButton = useCallback(() => {
     pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
-  };
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const onClickPageTitle = () => {
+  const onClickPageTitle = useCallback(() => {
     setEditedPagePath(currentPagePath);
     setRenameInputShown(true);
-  };
+  }, [currentPagePath]);
 
   const PageTitle = <div onClick={onClickPageTitle}>{pageTitle}</div>;
 

+ 3 - 2
apps/app/src/components/PageHistory/RevisionDiff.tsx

@@ -3,7 +3,8 @@ import React from 'react';
 import type { IRevisionHasPageId } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { createPatch } from 'diff';
-import { html, Diff2HtmlConfig } from 'diff2html';
+import type { Diff2HtmlConfig } from 'diff2html';
+import { html } from 'diff2html';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
@@ -43,7 +44,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     drawFileList: false,
   };
 
-  const diffViewHTML = (currentRevision.body && previousRevision.body && revisionDiffOpened) ? html(patch, option) : '';
+  const diffViewHTML = revisionDiffOpened ? html(patch, option) : '';
 
   const diffView = { __html: diffViewHTML };
 

+ 11 - 14
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { Suspense, useCallback } from 'react';
 
 import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -37,7 +37,7 @@ type TagsProps = {
 const Tags = (props: TagsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
-  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId, { suspense: true });
 
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
@@ -51,7 +51,7 @@ const Tags = (props: TagsProps): JSX.Element => {
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
   }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
-  if (!showTagLabel) {
+  if (!showTagLabel || tagsInfoData == null) {
     return <></>;
   }
 
@@ -59,16 +59,11 @@ const Tags = (props: TagsProps): JSX.Element => {
 
   return (
     <div className="grw-taglabels-container">
-      { tagsInfoData?.tags != null
-        ? (
-          <PageTags
-            tags={tagsInfoData.tags}
-            isTagLabelsDisabled={isTagLabelsDisabled}
-            onClickEditTagsButton={onClickEditTagsButton}
-          />
-        )
-        : <PageTagsSkeleton />
-      }
+      <PageTags
+        tags={tagsInfoData.tags}
+        isTagLabelsDisabled={isTagLabelsDisabled}
+        onClickEditTagsButton={onClickEditTagsButton}
+      />
     </div>
   );
 };
@@ -97,7 +92,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Tags */}
-      <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      <Suspense fallback={<PageTagsSkeleton />}>
+        <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      </Suspense>
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}

+ 3 - 2
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { Skeleton } from '../Skeleton';
 
@@ -23,7 +24,7 @@ export const PageTags:FC<Props> = (props: Props) => {
   } = props;
 
   if (tags == null) {
-    return <PageTagsSkeleton />;
+    return <></>;
   }
 
   const printNoneClass = tags.length === 0 ? 'd-print-none' : '';

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

@@ -91,7 +91,7 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const FormerLink = () => (
-    <div className={`${formerLinkClass} small`}>
+    <div className={`${formerLinkClass} ${isSmall ? 'text-truncate' : ''} small`}>
       <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
     </div>
   );

+ 1 - 1
apps/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 && 'rounded-pill'}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );
 };

+ 1 - 1
apps/app/src/interfaces/editor-settings.ts

@@ -1,4 +1,4 @@
-export const DEFAULT_THEME = 'elegant';
+export const DEFAULT_THEME = 'DefaultLight';
 
 const KeyMapMode = {
   default: 'default',

+ 1 - 0
apps/app/src/interfaces/page-tag-relation.ts

@@ -3,4 +3,5 @@ import type { IPage, ITag } from '@growi/core';
 export type IPageTagRelation = {
   relatedPage: IPage,
   relatedTag: ITag,
+  isPageTrashed: boolean,
 }

+ 6 - 2
apps/app/src/server/crowi/index.js

@@ -50,6 +50,12 @@ class Crowi {
   /** @type {AppService} */
   appService;
 
+  /** @type {import('../service/page').IPageService} */
+  pageService;
+
+  /** @type UserNotificationService */
+  userNotificationService;
+
   /** @type {FileUploader} */
   fileUploadService;
 
@@ -74,7 +80,6 @@ class Crowi {
     this.mailService = null;
     this.passportService = null;
     this.globalNotificationService = null;
-    this.userNotificationService = null;
     this.xssService = null;
     this.aclService = null;
     this.appService = null;
@@ -86,7 +91,6 @@ class Crowi {
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
-    this.pageService = null;
     this.syncPageStatusService = null;
     this.cdnResourcesService = new CdnResourcesService();
     this.slackIntegrationService = null;

+ 1 - 1
apps/app/src/server/events/activity.ts

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../crowi';
+import type Crowi from '../crowi';
 
 const logger = loggerFactory('growi:events:activity');
 

+ 6 - 3
apps/app/src/server/models/GlobalNotificationSetting.js → apps/app/src/server/models/GlobalNotificationSetting.ts

@@ -2,6 +2,7 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
+
 const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +11,7 @@ const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 /**
  * global notifcation event master
  */
-GlobalNotificationSettingSchema.statics.EVENT = {
+export const GlobalNotificationSettingEvent = {
   PAGE_CREATE: 'pageCreate',
   PAGE_EDIT: 'pageEdit',
   PAGE_DELETE: 'pageDelete',
@@ -22,13 +23,15 @@ GlobalNotificationSettingSchema.statics.EVENT = {
 /**
  * global notifcation type master
  */
-GlobalNotificationSettingSchema.statics.TYPE = {
+export const GlobalNotificationSettingType = {
   MAIL: 'mail',
   SLACK: 'slack',
 };
 
-module.exports = function(crowi) {
+const factory = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
   return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
 };
+
+export default factory;

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    GlobalNotificationSettingType.MAIL,
     new mongoose.Schema({
       toEmail: String,
     }, {

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    GlobalNotificationSettingType.SLACK,
     new mongoose.Schema({
       slackChannels: String,
     }, {

+ 19 - 0
apps/app/src/server/models/GlobalNotificationSetting/consts.ts

@@ -0,0 +1,19 @@
+/**
+ * global notifcation event master
+ */
+export const GlobalNotificationSettingEvent = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+export const GlobalNotificationSettingEventType = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};

+ 5 - 1
apps/app/src/server/models/index.js → apps/app/src/server/models/index.ts

@@ -1,3 +1,4 @@
+import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
 import Page from './page';
 
 export const modelsDependsOnCrowi = {
@@ -6,7 +7,7 @@ export const modelsDependsOnCrowi = {
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
@@ -19,5 +20,8 @@ export * as PageRedirect from './page-redirect';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';
+export * as PageTagRelation from './page-tag-relation';
 
 export * from './serializers';
+
+export * from './GlobalNotificationSetting';

+ 0 - 180
apps/app/src/server/models/page-tag-relation.js

@@ -1,180 +0,0 @@
-import Tag from './tag';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const flatMap = require('array.prototype.flatmap');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedPage: {
-    type: ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
-  },
-  relatedTag: {
-    type: ObjectId,
-    ref: 'Tag',
-    required: true,
-    index: true,
-  },
-  isPageTrashed: {
-    type: Boolean,
-    default: false,
-    required: true,
-    index: true,
-  },
-});
-// define unique compound index
-schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * PageTagRelation Class
- *
- * @class PageTagRelation
- */
-class PageTagRelation {
-
-  static async createTagListWithCount(option) {
-    const opt = option || {};
-    const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset;
-    const limit = opt.limit;
-
-    const tags = await this.aggregate()
-      .match({ isPageTrashed: false })
-      .lookup({
-        from: 'tags',
-        localField: 'relatedTag',
-        foreignField: '_id',
-        as: 'tag',
-      })
-      .unwind('$tag')
-      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
-      .sort(sortOpt)
-      .skip(offset)
-      .limit(limit);
-
-    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
-
-    return { data: tags, totalCount };
-  }
-
-  static async findByPageId(pageId, options = {}) {
-    const isAcceptRelatedTagNull = options.nullable || null;
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
-    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
-  }
-
-  static async listTagNamesByPage(pageId) {
-    const relations = await this.findByPageId(pageId);
-    return relations.map((relation) => { return relation.relatedTag.name });
-  }
-
-  /**
-   * @return {object} key: Page._id, value: array of tag names
-   */
-  static async getIdToTagNamesMap(pageIds) {
-    /**
-     * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
-     *
-     * results will be:
-     * [
-     *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
-     *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
-     *   ...
-     * ]
-     */
-    const results = await this.aggregate()
-      .match({ relatedPage: { $in: pageIds } })
-      .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
-
-    if (results.length === 0) {
-      return {};
-    }
-
-    results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
-
-    // extract distinct tag ids
-    const allTagIds = results
-      .flatMap(result => result.tagIds); // map + flatten
-    const distinctTagIds = Array.from(new Set(allTagIds));
-
-    // TODO: set IdToNameMap type by 93933
-    const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
-
-    // convert to map
-    const idToTagNamesMap = {};
-    results.forEach((result) => {
-      const tagNames = result.tagIds
-        .map(tagId => tagIdToNameMap[tagId])
-        .filter(tagName => tagName != null); // filter null object
-
-      idToTagNamesMap[result._id] = tagNames;
-    });
-
-    return idToTagNamesMap;
-  }
-
-  static async updatePageTags(pageId, tags) {
-    if (pageId == null || tags == null) {
-      throw new Error('args \'pageId\' and \'tags\' are required.');
-    }
-
-    // filter empty string
-    // eslint-disable-next-line no-param-reassign
-    tags = tags.filter((tag) => { return tag !== '' });
-
-    // get relations for this page
-    const relations = await this.findByPageId(pageId, { nullable: true });
-
-    const unlinkTagRelationIds = [];
-    const relatedTagNames = [];
-
-    relations.forEach((relation) => {
-      if (relation.relatedTag == null) {
-        unlinkTagRelationIds.push(relation._id);
-      }
-      else {
-        relatedTagNames.push(relation.relatedTag.name);
-        if (!tags.includes(relation.relatedTag.name)) {
-          unlinkTagRelationIds.push(relation._id);
-        }
-      }
-    });
-    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
-    // find or create tags
-    const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
-    const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
-
-    // create relations
-    const bulkCreatePromise = this.insertMany(
-      tagEntities.map((relatedTag) => {
-        return {
-          relatedPage: pageId,
-          relatedTag,
-        };
-      }),
-    );
-
-    return Promise.all([bulkDeletePromise, bulkCreatePromise]);
-  }
-
-}
-
-module.exports = function() {
-  schema.loadClass(PageTagRelation);
-  const model = mongoose.model('PageTagRelation', schema);
-  return model;
-};

+ 208 - 0
apps/app/src/server/models/page-tag-relation.ts

@@ -0,0 +1,208 @@
+import type { ITag } from '@growi/core';
+import type { Document, Model } from 'mongoose';
+import mongoose, { ObjectId } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
+
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { IdToNameMap } from './tag';
+import Tag from './tag';
+
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+
+const flatMap = require('array.prototype.flatmap');
+
+
+export interface PageTagRelationDocument extends IPageTagRelation, Document {
+}
+
+type CreateTagListWithCountOpts = {
+  sortOpt?: any,
+  offset?: number,
+  limit?: number,
+}
+type CreateTagListWithCountResult = {
+  data: ITag[],
+  totalCount: number
+}
+type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
+
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+
+type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
+
+export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
+  createTagListWithCount: CreateTagListWithCount
+  findByPageId(pageId: string, options?: { nullable?: boolean }): Promise<PageTagRelationDocument[]>
+  listTagNamesByPage(pageId: string): Promise<PageTagRelationDocument[]>
+  getIdToTagNamesMap: GetIdToTagNamesMap
+  updatePageTags: UpdatePageTags
+}
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  relatedTag: {
+    type: ObjectId,
+    ref: 'Tag',
+    required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
+  },
+});
+// define unique compound index
+schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+const createTagListWithCount: CreateTagListWithCount = async function(this, opts) {
+  const sortOpt = opts?.sortOpt || {};
+  const offset = opts?.offset ?? 0;
+  const limit = opts?.limit;
+
+  let query = this.aggregate()
+    .match({ isPageTrashed: false })
+    .lookup({
+      from: 'tags',
+      localField: 'relatedTag',
+      foreignField: '_id',
+      as: 'tag',
+    })
+    .unwind('$tag')
+    .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+    .sort(sortOpt)
+    .skip(offset);
+
+  if (limit != null) {
+    query = query.limit(limit);
+  }
+
+  const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+  return { data: await query.exec(), totalCount };
+};
+schema.statics.createTagListWithCount = createTagListWithCount;
+
+schema.statics.findByPageId = async function(pageId, options = {}) {
+  const isAcceptRelatedTagNull = options.nullable || null;
+  const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+  return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
+};
+
+schema.statics.listTagNamesByPage = async function(pageId) {
+  const relations = await this.findByPageId(pageId);
+  return relations.map((relation) => { return relation.relatedTag.name });
+};
+
+
+const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
+  /**
+   * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
+   *
+   * results will be:
+   * [
+   *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
+   *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
+   *   ...
+   * ]
+   */
+  const results = await this.aggregate<{ _id: ObjectId, tagIds: ObjectIdLike[] }>()
+    .match({ relatedPage: { $in: pageIds } })
+    .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
+
+  if (results.length === 0) {
+    return {};
+  }
+
+  results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
+
+  // extract distinct tag ids
+  const allTagIds = results
+    .flatMap(result => result.tagIds); // map + flatten
+  const distinctTagIds = Array.from(new Set(allTagIds));
+
+  // TODO: set IdToNameMap type by 93933
+  const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
+
+  // convert to map
+  const idToTagNamesMap = {};
+  results.forEach((result) => {
+    const tagNames = result.tagIds
+      .map(tagId => tagIdToNameMap[tagId.toString()])
+      .filter(tagName => tagName != null); // filter null object
+
+    idToTagNamesMap[result._id.toString()] = tagNames;
+  });
+
+  return idToTagNamesMap;
+};
+schema.statics.getIdToTagNamesMap = getIdToTagNamesMap;
+
+const updatePageTags: UpdatePageTags = async function(pageId, tags) {
+  if (pageId == null || tags == null) {
+    throw new Error('args \'pageId\' and \'tags\' are required.');
+  }
+
+  // filter empty string
+  // eslint-disable-next-line no-param-reassign
+  tags = tags.filter((tag) => { return tag !== '' });
+
+  // get relations for this page
+  const relations = await this.findByPageId(pageId, { nullable: true });
+
+  const unlinkTagRelationIds: string[] = [];
+  const relatedTagNames: string[] = [];
+
+  relations.forEach((relation) => {
+    if (relation.relatedTag == null) {
+      unlinkTagRelationIds.push(relation._id);
+    }
+    else {
+      relatedTagNames.push(relation.relatedTag.name);
+      if (!tags.includes(relation.relatedTag.name)) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+    }
+  });
+  const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
+  // find or create tags
+  const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+  const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
+
+  // create relations
+  const bulkCreatePromise = this.insertMany(
+    tagEntities.map((relatedTag) => {
+      return {
+        relatedPage: pageId,
+        relatedTag,
+      };
+    }),
+  );
+
+  await Promise.all([bulkDeletePromise, bulkCreatePromise]);
+};
+schema.statics.updatePageTags = updatePageTags;
+
+export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>('PageTagRelation', schema);

+ 7 - 4
apps/app/src/server/models/page.ts

@@ -8,26 +8,25 @@ import {
   type IGrantedGroup,
   GroupType, type HasObjectId,
 } from '@growi/core';
+import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
+import type { Model, Document, AnyObject } from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, AnyObject,
+  Schema,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -76,6 +75,10 @@ export interface PageModel extends Model<PageDocument> {
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
+  findTemplate(path: string): Promise<{
+    templateBody?: string,
+    templateTags?: string[],
+  }>
 
   PageQueryBuilder: typeof PageQueryBuilder
 

+ 3 - 4
apps/app/src/server/models/tag.ts

@@ -1,8 +1,7 @@
-import {
-  Types, Model, Schema,
-} from 'mongoose';
+import type { Types, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const mongoosePaginate = require('mongoose-paginate-v2');

+ 1 - 1
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,4 +1,4 @@
-import { Response } from 'express';
+import type { Response } from 'express';
 
 export interface ApiV3Response extends Response {
   apiv3(obj?: any, status?: number): any

+ 7 - 6
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GlobalNotificationSettingType } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -283,11 +284,11 @@ module.exports = (crowi) => {
 
     let notification;
 
-    if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+    if (notifyType === GlobalNotificationSettingType.MAIL) {
       notification = new GlobalNotificationMailSetting(crowi);
       notification.toEmail = toEmail;
     }
-    if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+    if (notifyType === GlobalNotificationSettingType.SLACK) {
       notification = new GlobalNotificationSlackSetting(crowi);
       notification.slackChannels = slackChannels;
     }
@@ -350,8 +351,8 @@ module.exports = (crowi) => {
     } = req.body;
 
     const models = {
-      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
-      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+      [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
     };
 
     try {
@@ -368,11 +369,11 @@ module.exports = (crowi) => {
         setting = setting.toObject();
       }
 
-      if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+      if (notifyType === GlobalNotificationSettingType.MAIL) {
         setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = toEmail;
       }
-      if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+      if (notifyType === GlobalNotificationSettingType.SLACK) {
         setting = GlobalNotificationSlackSetting.hydrate(setting);
         setting.slackChannels = slackChannels;
       }

+ 4 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
+  IPageInfoForListing, IPageInfo,
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -14,7 +14,6 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -76,7 +75,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     try {
       const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
@@ -95,7 +94,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
@@ -119,7 +118,7 @@ const routerFactory = (crowi: Crowi): Router => {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 

+ 234 - 0
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -0,0 +1,234 @@
+import type {
+  IGrantedGroup,
+  IPage, IUser, IUserHasId, PageGrant,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUniquePath(basePath: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = basePath + index;
+  const existingPageId = await Page.exists({ path, isEmpty: false });
+  if (existingPageId != null) {
+    return generateUniquePath(basePath, index + 1);
+  }
+  return path;
+}
+
+type ReqBody = {
+  path: string,
+
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+
+  body?: string,
+  overwriteScopesOfDescendants?: boolean,
+  isSlackEnabled?: boolean,
+  slackChannels?: any,
+  pageTags?: string[],
+  shouldGeneratePath?: boolean,
+}
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+
+  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  const validator: ValidationChain[] = [
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('path').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage('path is required'),
+    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
+      } = req.body;
+
+      let { path, grant, grantUserGroupIds } = req.body;
+
+      // check whether path starts slash
+      path = addHeadingSlash(path);
+
+      if (shouldGeneratePath) {
+        try {
+          const rootPath = '/';
+          const defaultTitle = '/Untitled';
+          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
+          path = await generateUniquePath(basePath);
+
+          // if the generated path is not creatable, create the path under the root path
+          if (!isCreatablePage(path)) {
+            path = await generateUniquePath(defaultTitle);
+            // initialize grant data
+            grant = 1;
+            grantUserGroupIds = undefined;
+          }
+        }
+        catch (err) {
+          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
+        }
+      }
+
+      if (!isCreatablePage(path)) {
+        return res.apiv3Err(`Could not use the path '${path}'`);
+      }
+
+      if (isUserPage(path)) {
+        const isExistUser = await User.isExistUserByUserPagePath(path);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+      if (grant != null) {
+        options.grant = grant;
+        options.grantUserGroupIds = grantUserGroupIds;
+      }
+
+      const isNoBodyPage = body === undefined;
+      let initialTags: string[] = [];
+      let initialBody = '';
+      if (isNoBodyPage) {
+        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+        if (isEnabledAttachTitleHeader) {
+          initialBody += `${attachTitleHeader(path)}\n`;
+        }
+
+        const templateData = await Page.findTemplate(path);
+        if (templateData.templateTags != null) {
+          initialTags = templateData.templateTags;
+        }
+        if (templateData.templateBody != null) {
+          initialBody += `${templateData.templateBody}\n`;
+        }
+      }
+
+      let createdPage;
+      try {
+        createdPage = await crowi.pageService.create(
+          path,
+          body ?? initialBody,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: createdPage,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      res.apiv3(result, 201);
+
+      try {
+      // global notification
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+
+      // user notification
+      if (isSlackEnabled) {
+        try {
+          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+          results.forEach((result) => {
+            if (result.status === 'rejected') {
+              logger.error('Create user notification failed', result.reason);
+            }
+          });
+        }
+        catch (err) {
+          logger.error('Create user notification failed', err);
+        }
+      }
+
+      // create subscription
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
+    },
+  ];
+};

+ 8 - 8
apps/app/src/server/routes/apiv3/page.js → apps/app/src/server/routes/apiv3/page/index.js

@@ -12,8 +12,10 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { GlobalNotificationSettingEvent } from '~/server/models';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
@@ -166,16 +168,14 @@ const router = express.Router();
  *            example: 5e07345972560e001761fa63
  */
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const configManager = crowi.configManager;
-
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const activityEvent = crowi.event('activity');
@@ -372,7 +372,7 @@ module.exports = (crowi) => {
     if (isLiked) {
       try {
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
       }
       catch (err) {
         logger.error('Like notification failed', err);

+ 19 - 194
apps/app/src/server/routes/apiv3/pages.js → apps/app/src/server/routes/apiv3/pages/index.js

@@ -2,25 +2,27 @@
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { GlobalNotificationSettingEvent } from '~/server/models';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
-import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import { serializePageSecurely } from '../../../models/serializers/page-serializer';
+import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
+import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
+import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
-const { body } = require('express-validator');
-const { query } = require('express-validator');
-const mongoose = require('mongoose');
-
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -144,40 +146,21 @@ const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  */
 
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const activityEvent = crowi.event('activity');
 
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-  const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const validator = {
-    createPage: [
-      body('body').optional().isString()
-        .withMessage('body must be string or undefined'),
-      body('path').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('path is required'),
-      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-      body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-    ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -222,33 +205,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  async function createPageAction({
-    path, body, user, options,
-  }) {
-    const createdPage = await crowi.pageService.create(path, body, user, options);
-    return createdPage;
-  }
-
-  async function saveTagsAction({ createdPage, pageTags }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  async function generateUniquePath(basePath, index = 1) {
-    const path = basePath + index;
-    const existingPageId = await Page.exists({ path, isEmpty: false });
-    if (existingPageId != null) {
-      return generateUniquePath(basePath, index + 1);
-    }
-    return path;
-  }
-
   /**
    * @swagger
    *
@@ -304,137 +260,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
-    const {
-      // body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-    } = req.body;
-
-    let { path, grant, grantUserGroupIds } = req.body;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    path = addHeadingSlash(path);
-
-    if (shouldGeneratePath) {
-      try {
-        const rootPath = '/';
-        const defaultTitle = '/Untitled';
-        const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-        path = await generateUniquePath(basePath);
-
-        // if the generated path is not creatable, create the path under the root path
-        if (!isCreatablePage(path)) {
-          path = await generateUniquePath(defaultTitle);
-          // initialize grant data
-          grant = 1;
-          grantUserGroupIds = undefined;
-        }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-      }
-    }
-
-    if (!isCreatablePage(path)) {
-      return res.apiv3Err(`Could not use the path '${path}'`);
-    }
-
-    if (isUserPage(path)) {
-      const isExistUser = await User.isExistUserByUserPagePath(path);
-      if (!isExistUser) {
-        return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-      }
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const isNoBodyPage = body === undefined;
-    let initialTags = [];
-    let initialBody = '';
-    if (isNoBodyPage) {
-      const isEnabledAttachTitleHeader = await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-      if (isEnabledAttachTitleHeader) {
-        initialBody += `${attachTitleHeader(path)}\n`;
-      }
-
-      const templateData = await Page.findTemplate(path);
-      if (templateData?.templateTags != null) {
-        initialTags = templateData.templateTags;
-      }
-      if (templateData?.templateBody != null) {
-        initialBody += `${templateData.templateBody}\n`;
-      }
-    }
-
-    let createdPage;
-    try {
-      createdPage = await createPageAction({
-        path, body: isNoBodyPage ? initialBody : body, user: req.user, options,
-      });
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.', err);
-      return res.apiv3Err(err);
-    }
-
-    const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : pageTags });
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      tags: savedTags,
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: createdPage,
-      action: SupportedAction.ACTION_PAGE_CREATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    res.apiv3(result, 201);
-
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    // create subscription
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
-  });
-
+  router.post('/', createPageHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -471,7 +297,6 @@ module.exports = (crowi) => {
         }
       });
 
-      const PageTagRelation = mongoose.model('PageTagRelation');
       const ids = result.pages.map((page) => { return page._id });
       const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
 
@@ -622,7 +447,7 @@ module.exports = (crowi) => {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
         oldPath: page.path,
       });
     }
@@ -840,7 +665,7 @@ module.exports = (crowi) => {
       const copyPage = { ...page };
       copyPage.path = newPagePath;
       try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, copyPage, req.user);
       }
       catch (err) {
         logger.error('Create grobal notification failed', err);

+ 2 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,7 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -281,7 +282,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.COMMENT, page, req.user, {
         comment: createdComment,
       });
     }

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,7 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
@@ -130,7 +129,6 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
   apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);

+ 5 - 243
apps/app/src/server/routes/page.js

@@ -5,7 +5,9 @@ import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
+import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import { preNotifyService } from '../service/pre-notify';
 
@@ -137,12 +139,9 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
+  const { pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
@@ -221,171 +220,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.list:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: listPages
-   *        summary: /pages.list
-   *        description: Get list of pages
-   *        parameters:
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: user
-   *            schema:
-   *              $ref: '#/components/schemas/User/properties/username'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of pages.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                      description: page list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.list List pages by user
-   * @apiName ListPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   * @apiParam {String} user
-   */
-  api.list = async function(req, res) {
-    const username = req.query.user || null;
-    const path = req.query.path || null;
-    const limit = +req.query.limit || 50;
-    const offset = parseInt(req.query.offset) || 0;
-
-    const queryOptions = { offset, limit: limit + 1 };
-
-    // Accepts only one of these
-    if (username === null && path === null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-    if (username !== null && path !== null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-
-    try {
-      let result = null;
-      if (path == null) {
-        const user = await User.findUserByUsername(username);
-        if (user === null) {
-          throw new Error('The user not found.');
-        }
-        result = await Page.findListByCreator(user, req.user, queryOptions);
-      }
-      else {
-        result = await Page.findListByStartWith(path, req.user, queryOptions);
-      }
-
-      if (result.pages.length > limit) {
-        result.pages.pop();
-      }
-
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  // TODO If everything that depends on this route, delete it too
-  api.create = async function(req, res) {
-    const body = req.body.body || null;
-    let pagePath = req.body.path || null;
-    const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    if (body === null || pagePath === null) {
-      return res.json(ApiResponse.error('Parameters body and path are required.'));
-    }
-
-    // check whether path starts slash
-    pagePath = pathUtils.addHeadingSlash(pagePath);
-
-    // check page existence
-    const isExist = await Page.count({ path: pagePath }) > 0;
-    if (isExist) {
-      return res.json(ApiResponse.error('Page exists', 'already_exists'));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-  };
-
   /**
    * @swagger
    *
@@ -502,7 +336,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
     }
     catch (err) {
       logger.error('Edit notification failed', err);
@@ -812,7 +646,7 @@ module.exports = function(crowi, app) {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_DELETE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_DELETE, page, req.user);
     }
     catch (err) {
       logger.error('Delete notification failed', err);
@@ -867,78 +701,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.duplicate:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: duplicatePage
-   *        summary: /pages.duplicate
-   *        description: Duplicate page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  new_path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to duplicate page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    tags:
-   *                      $ref: '#/components/schemas/Tags'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.duplicate Duplicate page
-   * @apiName DuplicatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} new_path New path name.
-   */
-  api.duplicate = async function(req, res) {
-    const pageId = req.body.page_id;
-    let newPagePath = pathUtils.normalizePath(req.body.new_path);
-
-    const page = await Page.findByIdAndViewer(pageId, req.user);
-
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // check whether path starts slash
-    newPagePath = pathUtils.addHeadingSlash(newPagePath);
-
-    await page.populateDataToShowRevision();
-    const originTags = await page.findRelatedTagsById();
-
-    req.body.path = newPagePath;
-    req.body.body = page.revision.body;
-    req.body.grant = page.grant;
-    req.body.grantedUsers = page.grantedUsers;
-    req.body.grantUserGroupIds = page.grantedGroups;
-    req.body.pageTags = originTags;
-
-    return api.create(req, res);
-  };
-
   /**
    * @api {post} /pages.unlink Remove the redirecting page
    * @apiName UnlinkPage

+ 3 - 3
apps/app/src/server/routes/tag.js

@@ -1,6 +1,9 @@
 import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
+import PageTagRelation from '../models/page-tag-relation';
+import ApiResponse from '../util/apiResponse';
+
 /**
  * @swagger
  *
@@ -32,9 +35,7 @@ import Tag from '~/server/models/tag';
  */
 module.exports = function(crowi, app) {
 
-  const PageTagRelation = crowi.model('PageTagRelation');
   const activityEvent = crowi.event('activity');
-  const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
@@ -138,7 +139,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const PageTagRelation = crowi.model('PageTagRelation');
     const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;

+ 10 - 10
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,8 +1,10 @@
+import nodePath from 'path';
+
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
-const nodePath = require('path');
 
 /**
  * sub service class of GlobalNotificationSetting
@@ -11,8 +13,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
   /**
@@ -29,7 +29,7 @@ class GlobalNotificationMailService {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, GlobalNotificationSettingType.MAIL);
 
     const option = this.generateOption(event, page, triggeredBy, vars);
 
@@ -73,19 +73,19 @@ class GlobalNotificationMailService {
     };
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
@@ -99,11 +99,11 @@ class GlobalNotificationMailService {
         };
         break;
 
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);

+ 15 - 17
apps/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
-import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import loggerFactory from '~/utils/logger';
 
 import {
   prepareSlackMessageForGlobalNotification,
@@ -18,9 +19,6 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
 
@@ -39,7 +37,7 @@ class GlobalNotificationSlackService {
     const { appService, slackIntegrationService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, GlobalNotificationSettingType.SLACK);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
@@ -74,16 +72,16 @@ class GlobalNotificationSlackService {
     let messageBody;
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         messageBody = `:bell: ${username} created ${parmaLink}`;
         break;
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         messageBody = `:bell: ${username} edited ${parmaLink}`;
         break;
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         messageBody = `:bell: ${username} deleted ${pathLink}`;
         break;
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -91,10 +89,10 @@ class GlobalNotificationSlackService {
         // eslint-disable-next-line no-case-declarations
         messageBody = `:bell: ${username} moved ${oldPath} to ${parmaLink}`;
         break;
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         messageBody = `:bell: ${username} liked ${parmaLink}`;
         break;
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -128,17 +126,17 @@ class GlobalNotificationSlackService {
     // attachment body is intended for comment or page diff
 
     // switch (event) {
-    //   case this.event.PAGE_CREATE:
+    //   case GlobalNotificationSettingEvent.PAGE_CREATE:
     //     break;
-    //   case this.event.PAGE_EDIT:
+    //   case GlobalNotificationSettingEvent.PAGE_EDIT:
     //     break;
-    //   case this.event.PAGE_DELETE:
+    //   case GlobalNotificationSettingEvent.PAGE_DELETE:
     //     break;
-    //   case this.event.PAGE_MOVE:
+    //   case GlobalNotificationSettingEvent.PAGE_MOVE:
     //     break;
-    //   case this.event.PAGE_LIKE:
+    //   case GlobalNotificationSettingEvent.PAGE_LIKE:
     //     break;
-    //   case this.event.COMMENT:
+    //   case GlobalNotificationSettingEvent.COMMENT:
     //     break;
     //   default:
     //     throw new Error(`unknown global notificaiton event: ${event}`);

+ 2 - 2
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -90,12 +90,12 @@ describe('delete-completely-user-home-by-system test', () => {
     const mockPageEvent = mock<EventEmitter>();
     const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
 
-    const mockPageService: IPageService = {
+    const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
       deleteCompletelyOperation: mockDeleteCompletelyOperation,
       getEventEmitter: () => mockPageEvent,
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
-    };
+    });
 
     it('should call used page service functions', async() => {
       // when

+ 6 - 25
apps/app/src/server/service/page/index.ts

@@ -33,6 +33,8 @@ import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawDa
 import {
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
+import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
@@ -43,7 +45,7 @@ import { PathAlreadyExistsError } from '../../models/errors';
 import type { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
-import type { PageRedirectModel } from '../../models/page-redirect';
+import PageRedirect from '../../models/page-redirect';
 import { serializePageSecurely } from '../../models/serializers/page-serializer';
 import ShareLink from '../../models/share-link';
 import Subscription from '../../models/subscription';
@@ -642,7 +644,6 @@ class PageService implements IPageService {
 
     // create page redirect
     if (options.createRedirectPage) {
-      const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
     this.pageEvent.emit('rename');
@@ -827,7 +828,6 @@ class PageService implements IPageService {
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
     if (createRedirectPage) {
-      const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
 
@@ -843,7 +843,6 @@ class PageService implements IPageService {
     }
 
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const { updateMetadata, createRedirectPage } = options;
 
@@ -911,7 +910,6 @@ class PageService implements IPageService {
   }
 
   private async renameDescendantsV4(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const pageCollection = mongoose.connection.collection('pages');
     const { updateMetadata, createRedirectPage } = options;
 
@@ -1069,7 +1067,6 @@ class PageService implements IPageService {
     }
 
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
 
     if (!isRecursively && page.isEmpty) {
       throw Error('Page not found.');
@@ -1146,7 +1143,7 @@ class PageService implements IPageService {
 
     // 4. Take over tags
     const originTags = await page.findRelatedTagsById();
-    let savedTags = [];
+    let savedTags: PageTagRelationDocument[] = [];
     if (originTags.length !== 0) {
       await PageTagRelation.updatePageTags(duplicatedTarget._id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(duplicatedTarget._id);
@@ -1240,7 +1237,6 @@ class PageService implements IPageService {
   }
 
   async duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources: boolean) {
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
 
@@ -1263,7 +1259,7 @@ class PageService implements IPageService {
 
     // take over tags
     const originTags = await page.findRelatedTagsById();
-    let savedTags = [];
+    let savedTags: PageTagRelationDocument[] = [];
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
@@ -1280,8 +1276,6 @@ class PageService implements IPageService {
    * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
    */
   private async duplicateTags(pageIdMapping) {
-    const PageTagRelation = mongoose.model('PageTagRelation');
-
     // convert pageId from string to ObjectId
     const pageIds = Object.keys(pageIdMapping);
     const stage = { $or: pageIds.map((pageId) => { return { relatedPage: new mongoose.Types.ObjectId(pageId) } }) };
@@ -1643,8 +1637,6 @@ class PageService implements IPageService {
 
   private async deleteNonEmptyTarget(page, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     const newPath = Page.getDeletedPageName(page.path);
 
     const deletedPage = await Page.findByIdAndUpdate(page._id, {
@@ -1684,9 +1676,7 @@ class PageService implements IPageService {
 
   private async deletePageV4(page, user, options = {}, isRecursively = false) {
     const Page = mongoose.model('Page') as PageModel;
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const newPath = Page.getDeletedPageName(page.path);
     const isTrashed = isTrashPage(page.path);
@@ -1728,7 +1718,6 @@ class PageService implements IPageService {
 
   private async deleteDescendants(pages, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const deletePageOperations: any[] = [];
     const insertPageRedirectOperations: any[] = [];
@@ -1851,9 +1840,7 @@ class PageService implements IPageService {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
@@ -2133,7 +2120,6 @@ class PageService implements IPageService {
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const revertPageOperations: any[] = [];
     const fromPathsToDelete: string[] = [];
@@ -2171,7 +2157,6 @@ class PageService implements IPageService {
      * Common Operation
      */
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
 
     const parameters = {
       ip: activityParameters.ip,
@@ -2331,7 +2316,6 @@ class PageService implements IPageService {
 
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
-    const PageTagRelation = this.crowi.model('PageTagRelation');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const originPage = await Page.findByPath(newPath);
@@ -2569,7 +2553,7 @@ class PageService implements IPageService {
 
   }
 
-  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user): Promise<Record<string, string | null>> {
+  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user?): Promise<Record<string, string | null>> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const MAX_LENGTH = 350;
 
@@ -3851,9 +3835,6 @@ class PageService implements IPageService {
    * Used to run sub operation in create method
    */
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
 

+ 11 - 1
apps/app/src/server/service/page/page-service.ts

@@ -1,12 +1,22 @@
 import type EventEmitter from 'events';
 
-import type { IUser } from '@growi/core';
+import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type { ObjectId } from 'mongoose';
 
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument } from '~/server/models/page';
 
 export interface IPageService {
+  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
+  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }

+ 6 - 7
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -7,18 +7,18 @@ import streamToPromise from 'stream-to-promise';
 
 import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import {
-  ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
-} from '~/interfaces/search';
+import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SocketEventName } from '~/interfaces/websocket';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import loggerFactory from '~/utils/logger';
 
-import {
+import type {
   SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
-import { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -460,7 +460,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 

+ 2 - 3
apps/app/src/server/service/user-notification/index.ts

@@ -31,7 +31,7 @@ export class UserNotificationService {
    * @param {Comment} comment
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: string }, comment = {}): Promise<PromiseSettledResult<any>[]> {
     const {
       appService, slackIntegrationService,
     } = this.crowi;
@@ -43,8 +43,7 @@ export class UserNotificationService {
     // update slackChannels attribute asynchronously
     page.updateSlackChannels(slackChannelsStr);
 
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
+    const { previousRevision } = option ?? {};
 
     // "dev,slacktest" => [dev,slacktest]
     const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);

+ 3 - 32
apps/app/src/stores/modal.tsx

@@ -3,11 +3,11 @@ import { useCallback, useMemo } from 'react';
 import type {
   IAttachmentHasId, IPageToDeleteWithMeta, IPageToRenameWithMeta, IUserGroupHasId,
 } from '@growi/core';
-import { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+
 
-import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
 } from '~/interfaces/ui';
@@ -675,35 +675,6 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
   };
 };
 
-/*
- * LinkEditModal
- */
-type LinkEditModalStatus = {
-  isOpened: boolean,
-  defaultMarkdownLink?: Linker,
-  onSave?: (linkText: string) => void
-}
-
-type LinkEditModalUtils = {
-  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
-  close(): void,
-}
-
-export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
-
-  const initialStatus: LinkEditModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
-
-  return Object.assign(swrResponse, {
-    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
-      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
-    },
-    close: () => {
-      swrResponse.mutate({ isOpened: false });
-    },
-  });
-};
-
 /*
 * PageSelectModal
 */

+ 10 - 3
apps/app/src/stores/page.tsx

@@ -125,14 +125,21 @@ export const useSWRxPageByPath = (path?: string, config?: SWRConfiguration): SWR
   );
 };
 
-export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo | undefined, Error> => {
+export const useSWRxTagsInfo = (pageId: Nullable<string>, config?: SWRConfiguration): SWRResponse<IPageTagsInfo | null, Error> => {
   const { data: shareLinkId } = useShareLinkId();
 
   const endpoint = `/pages.getPageTag?pageId=${pageId}`;
 
-  return useSWRImmutable(
+  return useSWR(
     shareLinkId == null && pageId != null ? [endpoint, pageId] : null,
-    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId }).then(result => result),
+    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId })
+      .then(result => result)
+      .catch(getPageApiErrorHandler),
+    {
+      ...config,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
   );
 };
 

+ 0 - 2
apps/app/test/integration/models/v5.page.test.js

@@ -16,7 +16,6 @@ describe('Page', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -488,7 +487,6 @@ describe('Page', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 1 - 2
apps/app/test/integration/service/page.test.js

@@ -3,6 +3,7 @@ import { GroupType } from '@growi/core';
 import { advanceTo } from 'jest-date-mock';
 
 import { PageSingleDeleteCompConfigValue, PageRecursiveDeleteCompConfigValue } from '~/interfaces/page-delete-config';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import Tag from '~/server/models/tag';
 import UserGroup from '~/server/models/user-group';
 import UserGroupRelation from '~/server/models/user-group-relation';
@@ -62,7 +63,6 @@ describe('PageService', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -75,7 +75,6 @@ describe('PageService', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 65 - 8
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -5,6 +5,8 @@ import mongoose from 'mongoose';
 import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import UserGroup from '../../../src/server/models/user-group';
 import UserGroupRelation from '../../../src/server/models/user-group-relation';
@@ -29,7 +31,6 @@ describe('PageService page operations with non-public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let xssSpy;
 
   let rootPage;
@@ -121,7 +122,6 @@ describe('PageService page operations with non-public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
 
     /*
      * Common
@@ -1154,7 +1154,7 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Duplicate', () => {
 
-    const duplicate = async(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources) => {
+    const duplicate = async(page, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean) => {
       // mock return value
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
       const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
@@ -1274,7 +1274,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
-    test('Should duplicate only user related resources when onlyDuplicateUserRelatedResources is true', async() => {
+    test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
       const _path1 = '/np_duplicate7';
       const _path2 = '/np_duplicate7/np_duplicate8';
       const _path3 = '/np_duplicate7/np_duplicate9';
@@ -1308,6 +1308,63 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.body).toBe(_revision1.body);
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
     });
+    test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _revision1 = _page1.revision;
+      const _revision2 = _page2.revision;
+      const _revision3 = _page3.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+      expect(_revision2).toBeTruthy();
+      expect(_revision3).toBeTruthy();
+
+      const newPagePath = '/dup2_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      const duplicatedRevision2 = duplicatedPage2.revision;
+      const duplicatedRevision3 = duplicatedPage3.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeTruthy();
+      expect(duplicatedPage3).toBeTruthy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(duplicatedRevision2).toBeTruthy();
+      expect(duplicatedRevision3).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        { item: groupIdB, type: GroupType.userGroup },
+        { item: externalGroupIdB, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
+        { item: groupIdC, type: GroupType.userGroup },
+        { item: externalGroupIdC, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.body).toBe(_revision2.body);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+      expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
+      expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.body).toBe(_revision3.body);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+    });
 
   });
   describe('Delete', () => {
@@ -1527,7 +1584,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
@@ -1536,7 +1593,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage.parent).toBeNull();
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage.grant).toBe(Page.GRANT_RESTRICTED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test('should revert single deleted page with GRANT_USER_GROUP', async() => {
       const beforeRevertPath = '/trash/np_revert2';
@@ -1557,7 +1614,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();
@@ -1569,7 +1626,7 @@ describe('PageService page operations with non-public pages', () => {
         { item: groupIdA, type: GroupType.userGroup },
         { item: externalGroupIdA, type: GroupType.externalUserGroup },
       ]);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test(`revert multiple pages: only target page should be reverted.
           Non-existant middle page and leaf page with GRANT_RESTRICTED shoud not be reverted`, async() => {

+ 0 - 2
apps/app/test/integration/service/v5.page.test.ts

@@ -11,7 +11,6 @@ describe('Test page service methods', () => {
   let Revision;
   let User;
   let Tag;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -42,7 +41,6 @@ describe('Test page service methods', () => {
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
     Tag = mongoose.model('Tag');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');

+ 10 - 11
apps/app/test/integration/service/v5.public-page.test.ts

@@ -1,8 +1,9 @@
 /* eslint-disable no-unused-vars */
-import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
 import { PageActionType, PageActionStage } from '../../../src/interfaces/page-operation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
@@ -15,7 +16,6 @@ describe('PageService page operations with only public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -49,7 +49,6 @@ describe('PageService page operations with only public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -2016,13 +2015,13 @@ describe('PageService page operations with only public pages', () => {
         endpoint: '/_api/v3/pages/delete',
       });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
-      const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
-      const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
+      const deletedTagRelation1 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation1?._id });
+      const deletedTagRelation2 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation2?._id });
 
       expect(page).toBe(null);
       expect(deletedPage.status).toBe(Page.STATUS_DELETED);
-      expect(deletedTagRelation1.isPageTrashed).toBe(true);
-      expect(deletedTagRelation2.isPageTrashed).toBe(true);
+      expect(deletedTagRelation1?.isPageTrashed).toBe(true);
+      expect(deletedTagRelation2?.isPageTrashed).toBe(true);
     });
   });
   describe('Delete completely', () => {
@@ -2103,7 +2102,7 @@ describe('PageService page operations with only public pages', () => {
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
-      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
+      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1?._id, pageTagRelation2?._id] } });
       const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
       const deletedComments = await Comment.find({ _id: comment._id });
       const deletedPageRedirects = await PageRedirect.find({ _id: { $in: [pageRedirect1._id, pageRedirect2._id] } });
@@ -2115,7 +2114,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedRevisions.length).toBe(0);
       // tag should be Truthy
       expect(tags).toBeTruthy();
-      // pageTagRelation should be null
+      // PageTagRelation should be null
       expect(deletedPageTagRelations.length).toBe(0);
       // bookmark should be null
       expect(deletedBookmarks.length).toBe(0);
@@ -2201,12 +2200,12 @@ describe('PageService page operations with only public pages', () => {
         ip: '::ffff:127.0.0.1',
         endpoint: '/_api/v3/pages/revert',
       });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.path).toBe('/v5_revert1');
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
 
     });
 

+ 3 - 3
apps/app/test/integration/setup-crowi.js → apps/app/test/integration/setup-crowi.ts

@@ -1,8 +1,8 @@
 import { Server } from 'http';
 
-import Crowi from '~/server/crowi';
+import Crowi from '../../src/server/crowi';
 
-let _instance = null;
+let _instance: Crowi;
 
 const initCrowi = async(crowi) => {
   await crowi.setupModels();
@@ -27,7 +27,7 @@ const initCrowi = async(crowi) => {
   ]);
 };
 
-export async function getInstance(isNewInstance) {
+export async function getInstance(isNewInstance?: boolean): Promise<Crowi> {
   if (isNewInstance) {
     const crowi = new Crowi();
     await initCrowi(crowi);

+ 1 - 0
packages/core/scss/bootstrap/_variables.scss

@@ -115,6 +115,7 @@ $font-family-base: $font-family-sans-serif;
 // $dropdown-link-disabled-color: $gray-500;
 // $dropdown-header-color: $gray-500;
 // $dropdown-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
+$dropdown-header-padding-y:         0 !default;
 
 //== Popovers
 // $popover-border-radius: $border-radius;

+ 17 - 13
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -1,14 +1,16 @@
 import {
-  forwardRef, useMemo, useRef, useEffect,
+  forwardRef, useMemo, useRef, useEffect, useState,
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
-import { Prec } from '@codemirror/state';
+import { Prec, Extension } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
-import { useFileDropzone, FileDropzoneOverlay, AllEditorTheme } from '../../services';
+import {
+  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme,
+} from '../../services';
 import {
   adjustPasteData, getStrFromBol,
 } from '../../services/list-util/markdown-list-util';
@@ -139,22 +141,24 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [onScroll, codeMirrorEditor]);
 
+
+  const [themeExtension, setThemeExtension] = useState<Extension | undefined>(undefined);
   useEffect(() => {
-    if (editorTheme == null) {
-      return;
-    }
-    if (AllEditorTheme[editorTheme] == null) {
+    const settingTheme = async(name?: EditorTheme) => {
+      setThemeExtension(await getEditorTheme(name ?? 'DefaultLight'));
+    };
+    settingTheme(editorTheme as EditorTheme);
+  }, [codeMirrorEditor, editorTheme, setThemeExtension]);
+
+  useEffect(() => {
+    if (themeExtension == null) {
       return;
     }
-
-    const extension = AllEditorTheme[editorTheme];
-
     // React CodeMirror has default theme which is default prec
     // and extension have to be higher prec here than default theme.
-    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(extension));
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(themeExtension));
     return cleanupFunction;
-
-  }, [codeMirrorEditor, editorTheme]);
+  }, [codeMirrorEditor, themeExtension]);
 
   const {
     getRootProps,

+ 2 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx

@@ -16,7 +16,7 @@ export const AttachmentsButton = (props: Props): JSX.Element => {
   if (acceptedFileType === AcceptedUploadFileType.ALL) {
     return (
       <>
-        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
           <span className="material-symbols-outlined fs-5">attach_file</span>
           Files
         </DropdownItem>
@@ -26,7 +26,7 @@ export const AttachmentsButton = (props: Props): JSX.Element => {
   if (acceptedFileType === AcceptedUploadFileType.IMAGE) {
     return (
       <>
-        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
           <span className="material-symbols-outlined fs-5">image</span>
           Images
         </DropdownItem>

+ 6 - 8
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -5,19 +5,21 @@ import {
   DropdownItem,
 } from 'reactstrap';
 
+import type { GlobalCodeMirrorEditorKey } from '../../../consts';
 import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
 
 import { AttachmentsButton } from './AttachmentsButton';
-
+import { LinkEditButton } from './LinkEditButton';
 
 type Props = {
+  editorKey: string | GlobalCodeMirrorEditorKey,
   onFileOpen: () => void,
   acceptedFileType: AcceptedUploadFileType,
 }
 
 export const AttachmentsDropup = (props: Props): JSX.Element => {
+  const { onFileOpen, acceptedFileType, editorKey } = props;
 
-  const { onFileOpen, acceptedFileType } = props;
   return (
     <>
       <UncontrolledDropdown direction="up" className="lh-1">
@@ -25,16 +27,12 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
           <span className="material-symbols-outlined fs-6">add</span>
         </DropdownToggle>
         <DropdownMenu>
-          <DropdownItem className="d-flex gap-1 align-items-center" header>
-            <span className="material-symbols-outlined fs-5">add_circle_outline</span>
+          <DropdownItem className="mt-1" header>
             Attachments
           </DropdownItem>
           <DropdownItem divider />
           <AttachmentsButton onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
-          <DropdownItem className="d-flex gap-1 align-items-center">
-            <span className="material-symbols-outlined fs-5">link</span>
-            Link
-          </DropdownItem>
+          <LinkEditButton editorKey={editorKey} />
         </DropdownMenu>
       </UncontrolledDropdown>
     </>

+ 39 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/LinkEditButton.tsx

@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+
+import { DropdownItem } from 'reactstrap';
+
+import type { GlobalCodeMirrorEditorKey } from '../../../consts';
+import { getMarkdownLink, replaceFocusedMarkdownLinkWithEditor } from '../../../services/link-util/markdown-link-util';
+import { useCodeMirrorEditorIsolated } from '../../../stores';
+import { useLinkEditModal } from '../../../stores/use-link-edit-modal';
+
+type Props = {
+  editorKey: string | GlobalCodeMirrorEditorKey,
+}
+
+export const LinkEditButton = (props: Props): JSX.Element => {
+  const { editorKey } = props;
+  const { open: openLinkEditModal } = useLinkEditModal();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
+
+  const onClickOpenLinkEditModal = useCallback(() => {
+    const editor = codeMirrorEditor?.view;
+    if (editor == null) {
+      return;
+    }
+    const onSubmit = (linkText: string) => {
+      replaceFocusedMarkdownLinkWithEditor(editor, linkText);
+      return;
+    };
+
+    const defaultMarkdownLink = getMarkdownLink(editor);
+
+    openLinkEditModal(defaultMarkdownLink, onSubmit);
+  }, [codeMirrorEditor?.view, openLinkEditModal]);
+
+  return (
+    <DropdownItem className="d-flex gap-1 align-items-center" onClick={onClickOpenLinkEditModal}>
+      <span className="material-symbols-outlined fs-5">link</span>Link
+    </DropdownItem>
+  );
+};

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -22,7 +22,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
   const { editorKey, onFileOpen, acceptedFileType } = props;
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
-      <AttachmentsDropup onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
+      <AttachmentsDropup editorKey={editorKey} onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
       <TextFormatTools editorKey={editorKey} />
       <EmojiButton
         editorKey={editorKey}

+ 2 - 4
packages/editor/src/components/playground/PlaygroundController.tsx

@@ -3,9 +3,7 @@ import { useCallback } from 'react';
 import { useForm } from 'react-hook-form';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
-import {
-  AllEditorTheme,
-} from '../../services';
+import { AllEditorTheme } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 export const InitEditorValueRow = (): JSX.Element => {
@@ -102,7 +100,7 @@ const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
       <div className="row mt-3">
         <h2>default</h2>
         <div className="col">
-          {createItems(Object.keys(AllEditorTheme))}
+          {createItems(AllEditorTheme)}
         </div>
       </div>
     </>

+ 38 - 22
packages/editor/src/services/editor-theme/index.ts

@@ -1,26 +1,42 @@
 import { Extension } from '@codemirror/state';
-import { eclipse } from '@uiw/codemirror-theme-eclipse';
-import { kimbie } from '@uiw/codemirror-theme-kimbie';
-import { basicLight } from 'cm6-theme-basic-light';
-import { materialDark as materialDarkCM6 } from 'cm6-theme-material-dark';
-import { nord as nordCM6 } from 'cm6-theme-nord';
 
-import { ayu } from './ayu';
-import { cobalt } from './cobalt';
-import { originalDark } from './original-dark';
-import { originalLight } from './original-light';
-import { rosePine } from './rose-pine';
+export const getEditorTheme = async(themeName: EditorTheme): Promise<Extension> => {
+  switch (themeName) {
+    case 'Eclipse':
+      return (await import('@uiw/codemirror-theme-eclipse')).eclipse;
+    case 'Basic':
+      return (await import('cm6-theme-basic-light')).basicLight;
+    case 'Ayu':
+      return (await import('./ayu')).ayu;
+    case 'Rosé Pine':
+      return (await import('./rose-pine')).rosePine;
+    case 'DefaultDark':
+      return (await import('./original-dark')).originalDark;
+    case 'Material':
+      return (await import('cm6-theme-material-dark')).materialDark;
+    case 'Nord':
+      return (await import('cm6-theme-nord')).nord;
+    case 'Cobalt':
+      return (await import('./cobalt')).cobalt;
+    case 'Kimbie':
+      return (await import('@uiw/codemirror-theme-kimbie')).kimbie;
+  }
+  return (await import('./original-light')).originalLight;
+};
 
+const EditorTheme = {
+  DefaultLight: 'DefaultLight',
+  Eclipse: 'Eclipse',
+  Basic: 'Basic',
+  Ayu: 'Ayu',
+  'Rosé Pine': 'Rosé Pine',
+  DefaultDark: 'DefaultDark',
+  Material: 'Material',
+  Nord: 'Nord',
+  Cobalt: 'Cobalt',
+  Kimbie: 'Kimbie',
+} as const;
 
-export const AllEditorTheme: Record<string, Extension> = {
-  DefaultLight: originalLight,
-  Eclipse: eclipse,
-  Basic: basicLight,
-  Ayu: ayu,
-  'Rosé Pine': rosePine,
-  DefaultDark: originalDark,
-  Material: materialDarkCM6,
-  Nord: nordCM6,
-  Cobalt: cobalt,
-  Kimbie: kimbie,
-};
+
+export const AllEditorTheme = Object.values(EditorTheme);
+export type EditorTheme = typeof EditorTheme[keyof typeof EditorTheme]

+ 27 - 23
apps/app/src/client/models/Linker.js → packages/editor/src/services/link-util/Linker.ts

@@ -1,14 +1,14 @@
-
 import { encodeSpaces } from '@growi/core/dist/utils/page-path-utils';
 
 export default class Linker {
 
-  constructor(
-      type = Linker.types.markdownLink,
-      label = '',
-      link = '',
-  ) {
+  type: string;
+
+  label: string | undefined;
+
+  link: string | undefined;
 
+  constructor(type = Linker.types.markdownLink, label = '', link = '') {
     this.type = type;
     this.label = label;
     this.link = link;
@@ -33,7 +33,7 @@ export default class Linker {
     markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
   };
 
-  initWhenMarkdownLink() {
+  initWhenMarkdownLink(): void {
     // fill label with link if empty
     if (this.label === '') {
       this.label = this.link;
@@ -42,7 +42,7 @@ export default class Linker {
     this.link = encodeSpaces(this.link);
   }
 
-  generateMarkdownText() {
+  generateMarkdownText(): string | undefined {
     if (this.type === Linker.types.pukiwikiLink) {
       if (this.label === '') return `[[${this.link}]]`;
       return `[[${this.label}>${this.link}]]`;
@@ -56,7 +56,7 @@ export default class Linker {
   }
 
   // create an instance of Linker from string
-  static fromMarkdownString(str) {
+  static fromMarkdownString(str: string): Linker {
     // if str doesn't mean a linker, create a link whose label is str
     let label = str;
     let link = '';
@@ -65,23 +65,27 @@ export default class Linker {
     // pukiwiki with separator ">".
     if (str.match(this.patterns.pukiwikiLinkWithLabel)) {
       type = this.types.pukiwikiLink;
-      ({ label, link } = str.match(this.patterns.pukiwikiLinkWithLabel).groups);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      ({ label, link } = str.match(this.patterns.pukiwikiLinkWithLabel)!.groups!);
     }
     // pukiwiki without separator ">".
     else if (str.match(this.patterns.pukiwikiLinkWithoutLabel)) {
       type = this.types.pukiwikiLink;
-      ({ label } = str.match(this.patterns.pukiwikiLinkWithoutLabel).groups);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      ({ label } = str.match(this.patterns.pukiwikiLinkWithoutLabel)!.groups!);
       link = label;
     }
     // markdown
     else if (str.match(this.patterns.markdownLink)) {
       type = this.types.markdownLink;
-      ({ label, link } = str.match(this.patterns.markdownLink).groups);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      ({ label, link } = str.match(this.patterns.markdownLink)!.groups!);
     }
     // growi
     else if (str.match(this.patterns.growiLink)) {
       type = this.types.growiLink;
-      ({ label } = str.match(this.patterns.growiLink).groups);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      ({ label } = str.match(this.patterns.growiLink)!.groups!);
       link = label;
     }
 
@@ -93,7 +97,7 @@ export default class Linker {
   }
 
   // create an instance of Linker from text with index
-  static fromLineWithIndex(line, index) {
+  static fromLineWithIndex(line: string, index: number): Linker {
     const { beginningOfLink, endOfLink } = this.getBeginningAndEndIndexOfLink(line, index);
     // if index is in a link, extract it from line
     let linkStr = '';
@@ -103,11 +107,11 @@ export default class Linker {
     return this.fromMarkdownString(linkStr);
   }
 
-  // return beginning and end indexies of link
+  // return beginning and end indices of link
   // if index is not in a link, return { beginningOfLink: -1, endOfLink: -1 }
-  static getBeginningAndEndIndexOfLink(line, index) {
-    let beginningOfLink;
-    let endOfLink;
+  static getBeginningAndEndIndexOfLink(line: string, index: number): { beginningOfLink: number; endOfLink: number } {
+    let beginningOfLink: number;
+    let endOfLink: number;
 
     // pukiwiki link ('[[link]]')
     [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[[', ']]');
@@ -130,13 +134,13 @@ export default class Linker {
     return { beginningOfLink, endOfLink };
   }
 
-  // return begin and end indexies as array only when index is between prefix and suffix and link contains containText.
-  static getBeginningAndEndIndexWithPrefixAndSuffix(line, index, prefix, suffix, containText = '') {
+  // return begin and end indices as an array only when index is between prefix and suffix and link contains containText.
+  static getBeginningAndEndIndexWithPrefixAndSuffix(line: string, index: number, prefix: string, suffix: string, containText = ''): [number, number] {
     const beginningIndex = line.lastIndexOf(prefix, index);
-    const IndexOfContainText = line.indexOf(containText, beginningIndex + prefix.length);
-    const endIndex = line.indexOf(suffix, IndexOfContainText + containText.length);
+    const indexOfContainText = line.indexOf(containText, beginningIndex + prefix.length);
+    const endIndex = line.indexOf(suffix, indexOfContainText + containText.length);
 
-    if (beginningIndex < 0 || IndexOfContainText < 0 || endIndex < 0) {
+    if (beginningIndex < 0 || indexOfContainText < 0 || endIndex < 0) {
       return [-1, -1];
     }
     return [beginningIndex, endIndex + suffix.length];

+ 41 - 0
packages/editor/src/services/link-util/markdown-link-util.ts

@@ -0,0 +1,41 @@
+import type { EditorView } from '@codemirror/view';
+
+import Linker from './Linker';
+
+const curPos = (editor: EditorView) => {
+  return editor.state.selection.main.head;
+};
+
+const doc = (editor: EditorView) => {
+  return editor.state.doc;
+};
+
+const getCursorLine = (editor: EditorView) => {
+  return doc(editor).lineAt(curPos(editor));
+
+};
+
+export const isInLink = (editor: EditorView): boolean => {
+  const cursorLine = getCursorLine(editor);
+  const startPos = curPos(editor) - cursorLine.from;
+
+  const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(cursorLine.text, startPos);
+  return beginningOfLink >= 0 && endOfLink >= 0;
+};
+export const getMarkdownLink = (editor: EditorView): Linker => {
+  if (!isInLink(editor)) {
+    const selection = editor?.state.sliceDoc(
+      editor?.state.selection.main.from,
+      editor?.state.selection.main.to,
+    );
+    return Linker.fromMarkdownString(selection);
+  }
+
+  const cursorLine = getCursorLine(editor);
+  const startPos = curPos(editor) - cursorLine.from;
+  return Linker.fromLineWithIndex(cursorLine.text, startPos);
+};
+
+export const replaceFocusedMarkdownLinkWithEditor = (editor: EditorView, linkText: string): void => {
+  editor.dispatch(editor.state.replaceSelection(linkText));
+};

+ 30 - 0
packages/editor/src/stores/use-link-edit-modal.ts

@@ -0,0 +1,30 @@
+import { useSWRStatic } from '@growi/core/dist/swr';
+import { SWRResponse } from 'swr';
+
+import Linker from '../services/link-util/Linker';
+
+type LinkEditModalStatus = {
+  isOpened: boolean,
+  defaultMarkdownLink?: Linker,
+  onSave?: (linkText: string) => void
+}
+
+type LinkEditModalUtils = {
+  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
+  close(): void,
+}
+
+export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
+
+  const initialStatus: LinkEditModalStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
+      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

+ 11 - 0
packages/override/_dropdown.scss

@@ -0,0 +1,11 @@
+:root[data-bs-theme='light'] {
+  .dropdown-menu {
+    --#{$prefix}dropdown-header-color: var(--grw-gray-500);
+  }
+}
+
+:root[data-bs-theme='dark'] {
+  .dropdown-menu {
+    --#{$prefix}dropdown-header-color: var(--grw-gray-600);
+  }
+}