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

Merge branch 'master' into imprv/page-title-header-max-width

Yuki Takei пре 1 година
родитељ
комит
f5101dd12d
31 измењених фајлова са 933 додато и 491 уклоњено
  1. 1 0
      .github/mergify.yml
  2. 1 1
      .github/workflows/release-slackbot-proxy.yml
  3. 1 1
      .github/workflows/release.yml
  4. 7 7
      apps/app/package.json
  5. 1 1
      apps/app/public/static/locales/en_US/commons.json
  6. 1 1
      apps/app/public/static/locales/fr_FR/commons.json
  7. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  8. 1 1
      apps/app/public/static/locales/zh_CN/commons.json
  9. 10 1
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  10. 8 2
      apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx
  11. 3 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  12. 0 1
      apps/app/src/client/components/CustomNavigation/CustomNav.module.scss
  13. 19 3
      apps/app/src/client/components/CustomNavigation/CustomNav.tsx
  14. 9 1
      apps/app/src/client/components/DataTransferForm.tsx
  15. 4 0
      apps/app/src/client/components/DescendantsPageListModal.module.scss
  16. 70 0
      apps/app/src/client/components/DescendantsPageListModal.spec.tsx
  17. 25 9
      apps/app/src/client/components/DescendantsPageListModal.tsx
  18. 111 0
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  19. 10 2
      apps/app/src/pages/admin/data-transfer.page.tsx
  20. 2 1
      apps/app/src/pages/installer.page.tsx
  21. 20 2
      apps/app/src/stores-universal/context.tsx
  22. 15 14
      apps/app/test-with-vite/download-mongo-binary/vitest.config.ts
  23. 0 19
      apps/app/vitest.config.components.ts
  24. 0 23
      apps/app/vitest.config.integ.ts
  25. 0 19
      apps/app/vitest.config.ts
  26. 65 0
      apps/app/vitest.workspace.mts
  27. 7 7
      package.json
  28. 81 0
      packages/pluginkit/src/v4/utils/template.spec.ts
  29. 10 5
      packages/pluginkit/vitest.config.ts
  30. 2 0
      vitest.workspace.mts
  31. 448 367
      yarn.lock

+ 1 - 0
.github/mergify.yml

@@ -23,6 +23,7 @@ pull_request_rules:
   - name: Automatic queue to merge
     conditions:
       - '#approved-reviews-by >= 1'
+      - '#changes-requested-reviews-by = 0'
       - '#review-requested = 0'
       - check-success = check-title
     actions:

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -41,7 +41,7 @@ jobs:
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
 
     - name: Setup gcloud
-      uses: google-github-actions/setup-gcloud@v1
+      uses: google-github-actions/setup-gcloud@v2
 
     - name: Configure docker for gcloud
       run: |

+ 1 - 1
.github/workflows/release.yml

@@ -64,7 +64,7 @@ jobs:
         commit_message: Release v${{ steps.package-json.outputs.packageVersion }}
         tagging_message: v${{ steps.package-json.outputs.packageVersion }}
 
-    - uses: softprops/action-gh-release@v1
+    - uses: softprops/action-gh-release@v2
       with:
         body: ${{ github.event.pull_request.body }}
         tag_name: v${{ steps.package-json.outputs.packageVersion }}

+ 7 - 7
apps/app/package.json

@@ -35,12 +35,9 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
+    "test:vitest": "vitest run --coverage",
     "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
-    "vitest:run": "vitest run config src --coverage",
-    "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
-    "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn repl",
@@ -143,7 +140,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.1.3",
+    "next": "^14.2.13",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.2.0",
     "next-superjson": "^0.0.4",
@@ -229,13 +226,16 @@
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
-    "@testing-library/react": "^14.1.2",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/jest-dom": "^6.5.0",
+    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
@@ -249,7 +249,7 @@
     "eslint-plugin-regex": "^1.8.0",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
-    "happy-dom": "^13.2.0",
+    "happy-dom": "^15.7.4",
     "i18next-chained-backend": "^4.6.2",
     "i18next-hmr": "^3.0.4",
     "i18next-http-backend": "^2.5.0",

+ 1 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publish transfer key",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
-    "transfer_to_growi_cloud": "If you wish to transfer to GROWI.cloud, please click here."
+    "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
   }
 }

+ 1 - 1
apps/app/public/static/locales/fr_FR/commons.json

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publier la clé de transfert",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
-    "transfer_to_growi_cloud": "Si vous souhaitez transférer depuis GROWI.cloud, cliquez ici."
+    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
   }
 }

+ 1 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -159,6 +159,6 @@
     "publish_transfer_key": "移行キーを発行する",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
-    "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
+    "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
   }
 }

+ 1 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -160,6 +160,6 @@
     "publish_transfer_key": "发布迁移密钥",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
-    "transfer_to_growi_cloud": "如果您希望迁移到GROWI.cloud,请点击这里。"
+    "transfer_to_growi_cloud": "有关更多详情,请点击<a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
   }
 }

+ 10 - 1
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -8,6 +8,7 @@ import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import { useGrowiDocumentationUrl } from '~/stores-universal/context';
 import { useAdminSocket } from '~/stores/socket-io';
 
 import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
@@ -123,6 +124,8 @@ const G2GDataTransfer = (): JSX.Element => {
     }
   }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
 
+  const { data: documentationUrl } = useGrowiDocumentationUrl();
+
   // File upload
   // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
   //   setFileUploadType(type);
@@ -275,7 +278,13 @@ const G2GDataTransfer = (): JSX.Element => {
       <div className="alert alert-warning mt-4">
         <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
         <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
-        <p className="mb-0">{t('commons:g2g_data_transfer.transfer_to_growi_cloud')}</p>
+        <p
+          className="mb-0"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+          }}
+        />
       </div>
     </div>
   );

+ 8 - 2
apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx

@@ -14,7 +14,7 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
-import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+import CustomNav from '../../CustomNavigation/CustomNav';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -155,7 +155,13 @@ function NotificationSetting(props) {
 
       <h2 className="admin-setting-header mt-5">{t('notification_settings.notification_settings')}</h2>
 
-      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+      <CustomNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={switchActiveTab}
+        hideBorderBottom
+        breakpointToSwitchDropdownDown="md"
+      />
 
       <TabContent activeTab={activeTab} className="p-5">
         <TabPane tabId="user_trigger_notification">

+ 3 - 2
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,7 +1,8 @@
 import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
 import {
-  fireEvent, render, screen, within,
-} from '@testing-library/react';
+  fireEvent, screen, within,
+} from '@testing-library/dom';
+import { render } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 
 import { PageItemControl } from './PageItemControl';

+ 0 - 1
apps/app/src/client/components/CustomNavigation/CustomNav.module.scss

@@ -14,5 +14,4 @@
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
-
 }

+ 19 - 3
apps/app/src/client/components/CustomNavigation/CustomNav.tsx

@@ -42,26 +42,42 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
 
   const { Icon, i18n } = navTabMapping[activeTab];
 
+  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+  const dropdownButtonRef = useRef<HTMLButtonElement>(null);
+
+  const toggleDropdown = () => {
+    setIsDropdownOpen(prev => !prev);
+  };
+
   const menuItemClickHandler = useCallback((key) => {
     if (onNavSelected != null) {
       onNavSelected(key);
     }
+    // Manually close the dropdown
+    setIsDropdownOpen(false);
+    if (dropdownButtonRef.current) {
+      dropdownButtonRef.current.classList.remove('show');
+    }
   }, [onNavSelected]);
 
   return (
     <div className="btn-group">
       <button
+        ref={dropdownButtonRef}
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"
         data-bs-toggle="dropdown"
         aria-haspopup="true"
-        aria-expanded="false"
+        aria-expanded={isDropdownOpen}
+        onClick={toggleDropdown}
+        data-testid="custom-nav-dropdown"
       >
         <span className="float-start">
           { Icon != null && <Icon /> } {i18n}
         </span>
       </button>
-      <div className="dropdown-menu dropdown-menu-right">
+      <div className={`dropdown-menu dropdown-menu-right w-100 ${isDropdownOpen ? 'show' : ''} ${styles['dropdown-menu']}`}>
         {Object.entries(navTabMapping).map(([key, value]) => {
 
           const isActive = activeTab === key;
@@ -167,7 +183,7 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   }
 
   return (
-    <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
+    <div data-testid="custom-nav-tab" className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
       <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {

+ 9 - 1
apps/app/src/client/components/DataTransferForm.tsx

@@ -3,12 +3,14 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+import { useGrowiDocumentationUrl } from '~/stores-universal/context';
 
 import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
 
 const DataTransferForm = (): JSX.Element => {
   const { t } = useTranslation('commons');
   const { transferKey, generateTransferKey } = useGenerateTransferKey();
+  const { data: documentationUrl } = useGrowiDocumentationUrl();
 
   return (
     <div data-testid="installerForm" className="py-3 px-4">
@@ -33,7 +35,13 @@ const DataTransferForm = (): JSX.Element => {
       <div className="alert alert-warning mt-4">
         <p className="mb-1">{t('g2g_data_transfer.transfer_key_limit')}</p>
         <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
-        <p className="mb-0">{t('g2g_data_transfer.transfer_to_growi_cloud')}</p>
+        <p
+          className="mb-0"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+          }}
+        />
       </div>
     </div>
   );

+ 4 - 0
apps/app/src/client/components/DescendantsPageListModal.module.scss

@@ -9,6 +9,10 @@
     padding: 25px 30px;
   }
 
+  .grw-tab-content-style-md-down {
+    padding-top: 25px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 70 - 0
apps/app/src/client/components/DescendantsPageListModal.spec.tsx

@@ -0,0 +1,70 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { DescendantsPageListModal } from './DescendantsPageListModal';
+
+const mockClose = vi.hoisted(() => vi.fn());
+const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({
+    events: {
+      on: vi.fn(),
+      off: vi.fn(),
+    },
+  }),
+}));
+
+vi.mock('~/stores/modal', () => ({
+  useDescendantsPageListModal: vi.fn().mockReturnValue({
+    data: { isOpened: true },
+    close: mockClose,
+  }),
+}));
+
+vi.mock('~/stores/ui', () => ({
+  useIsDeviceLargerThanLg,
+}));
+
+describe('DescendantsPageListModal.tsx', () => {
+
+  it('should render the modal when isOpened is true', () => {
+    render(<DescendantsPageListModal />);
+    expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
+  });
+
+  it('should call close function when close button is clicked', () => {
+    render(<DescendantsPageListModal />);
+    const closeButton = screen.getByLabelText('Close');
+    fireEvent.click(closeButton);
+    expect(mockClose).toHaveBeenCalled();
+  });
+
+  describe('when device is larger than lg', () => {
+
+    it('should render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();
+    });
+
+    it('should not render CustomNavDropdown', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-dropdown')).toBeNull();
+    });
+  });
+
+  describe('when device is smaller than lg', () => {
+    beforeEach(() => {
+      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+    });
+
+    it('should render CustomNavDropdown on devices smaller than lg', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-dropdown')).not.toBeNull();
+    });
+
+    it('should not render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-tab')).toBeNull();
+    });
+  });
+});

+ 25 - 9
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -10,8 +10,9 @@ import {
 
 import { useIsSharedUser } from '~/stores-universal/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
+import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
 import type { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
@@ -34,6 +35,8 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { events } = useRouter();
 
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+
   useEffect(() => {
     events.on('routeChangeStart', close);
     return () => {
@@ -93,17 +96,30 @@ export const DescendantsPageListModal = (): JSX.Element => {
       data-testid="descendants-page-list-modal"
       className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      <ModalHeader className="p-0" toggle={close} close={buttons}>
-        <CustomNavTab
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={v => setActiveTab(v)}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody>
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={v => setActiveTab(v)}
+          />
+        )}
+        <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          breakpointToHideInactiveTabsDown="md"
-          onNavSelected={v => setActiveTab(v)}
-          hideBorderBottom
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
-      </ModalHeader>
-      <ModalBody>
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );

+ 111 - 0
apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx

@@ -0,0 +1,111 @@
+import '@testing-library/jest-dom/vitest';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import {
+  fireEvent, render, screen, waitFor,
+} from '@testing-library/react';
+import { mock } from 'vitest-mock-extended';
+
+import { EditorMode } from '~/stores-universal/ui';
+
+import { PageTitleHeader } from './PageTitleHeader';
+
+const mocks = vi.hoisted(() => ({
+  useIsUntitledPageMock: vi.fn(),
+  useEditorModeMock: vi.fn(() => ({ data: EditorMode.Editor })),
+}));
+
+vi.mock('~/stores/ui', () => ({
+  useIsUntitledPage: mocks.useIsUntitledPageMock,
+}));
+vi.mock('~/stores-universal/ui', async importOriginal => ({
+  ...await importOriginal(),
+  useEditorMode: mocks.useEditorModeMock,
+}));
+
+describe('PageTitleHeader Component with untitled page', () => {
+
+  beforeAll(() => {
+    mocks.useIsUntitledPageMock.mockImplementation(() => ({ data: true }));
+  });
+
+  it('should render the textbox correctly', async() => {
+    // arrange
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: 'dummy-id',
+      path: '/path/to/page/Untitled-1',
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    // assert
+    // header should be rendered
+    const headerElement = screen.getByText('Untitled-1');
+    const inputElement = screen.getByRole('textbox');
+    const inputElementByPlaceholder = screen.getByPlaceholderText('Input page name');
+    await waitFor(() => {
+      expect(inputElement).toBeInTheDocument();
+      expect(inputElement).toStrictEqual(inputElementByPlaceholder);
+      expect(inputElement).toHaveValue(''); // empty
+      expect(headerElement).toHaveClass('invisible');
+    });
+  });
+
+});
+
+
+describe('PageTitleHeader Component', () => {
+
+  beforeAll(() => {
+    mocks.useIsUntitledPageMock.mockImplementation(() => ({ data: false }));
+  });
+
+  it('should render the title correctly', async() => {
+    // arrange
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: 'dummy-id',
+      path: '/path/to/page/page-title',
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    // assert
+    // header should be rendered
+    const headerElement = screen.getByText('page-title');
+    await waitFor(() => {
+      expect(headerElement).toBeInTheDocument();
+      expect(headerElement).not.toHaveClass('invisible');
+    });
+    // textbox should not be rendered
+    const inputElement = screen.queryByRole('textbox');
+    expect(inputElement).not.toBeInTheDocument();
+  });
+
+  it('should render text input after clicking', async() => {
+    // arrange
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: 'dummy-id',
+      path: '/path/to/page/page-title',
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    const headerElement = screen.getByText('page-title');
+    await waitFor(() => expect(headerElement).toBeInTheDocument());
+
+    // click
+    fireEvent.click(headerElement);
+
+    // assert
+    const inputElement = screen.getByRole('textbox');
+    await waitFor(() => {
+      expect(inputElement).toBeInTheDocument();
+      expect(inputElement).toHaveValue('page-title');
+      expect(headerElement).toHaveClass('invisible');
+    });
+  });
+
+});

+ 10 - 2
apps/app/src/pages/admin/data-transfer.page.tsx

@@ -9,8 +9,9 @@ import Head from 'next/head';
 import type { Container } from 'unstated';
 import { Provider } from 'unstated';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CommonProps } from '~/pages/utils/commons';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores-universal/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -25,6 +26,7 @@ type Props = CommonProps;
 const DataTransferPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('commons');
   useCurrentUser(props.currentUser ?? null);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = t('g2g_data_transfer.data_transfer');
 
@@ -54,9 +56,15 @@ const DataTransferPage: NextPage<Props> = (props) => {
   );
 };
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+};
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const props = await retrieveServerSideProps(context);
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
   return props;
 };
 

+ 2 - 1
apps/app/src/pages/installer.page.tsx

@@ -11,7 +11,7 @@ import Head from 'next/head';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import {
-  useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
+  useCsrfToken, useAppTitle, useSiteUrl, useConfidential, useGrowiCloudUri,
 } from '~/stores-universal/context';
 
 import type { CommonProps } from './utils/commons';
@@ -57,6 +57,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useSiteUrl(props.siteUrl);
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = generateCustomTitle(props, t('installer.title'));
   const classNames: string[] = [];

+ 20 - 2
apps/app/src/stores-universal/context.tsx

@@ -1,5 +1,3 @@
-import { useCallback, useEffect } from 'react';
-
 import type EventEmitter from 'events';
 
 import { AcceptedUploadFileType } from '@growi/core';
@@ -285,3 +283,23 @@ export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType,
     },
   );
 };
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useGrowiDocumentationUrl = () => {
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  return useSWR(
+    ['documentationUrl', growiCloudUri],
+    ([, growiCloudUri]) => {
+      const url = growiCloudUri != null
+        ? new URL('/help', growiCloudUri)
+        : new URL('https://docs.growi.org');
+      return url.toString();
+    },
+    {
+      fallbackData: 'https://docs.growi.org',
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
+  );
+};

+ 15 - 14
apps/app/test-with-vite/download-mongo-binary/vitest.config.ts

@@ -1,15 +1,16 @@
-import { defineConfig, mergeConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
 
-import configShared from '../../vitest.config';
-
-export default mergeConfig(
-  configShared,
-  defineConfig({
-    test: {
-      hookTimeout: 60000, // increased for downloading MongoDB binary file
-      setupFiles: [
-        './test-with-vite/setup/mongoms.ts',
-      ],
-    },
-  }),
-);
+export default defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    clearMocks: true,
+    globals: true,
+    hookTimeout: 60000, // increased for downloading MongoDB binary file
+    setupFiles: [
+      './test-with-vite/setup/mongoms.ts',
+    ],
+  },
+});

+ 0 - 19
apps/app/vitest.config.components.ts

@@ -1,19 +0,0 @@
-import react from '@vitejs/plugin-react';
-import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
-
-export default defineConfig({
-  plugins: [
-    react(), tsconfigPaths(),
-  ],
-  test: {
-    globals: true,
-    environment: 'happy-dom',
-    include: [
-      '**/*.spec.{tsx,jsx}',
-    ],
-    coverage: {
-      reportsDirectory: './coverage/components',
-    },
-  },
-});

+ 0 - 23
apps/app/vitest.config.integ.ts

@@ -1,23 +0,0 @@
-import { defineConfig, mergeConfig } from 'vitest/config';
-
-import configShared from './vitest.config';
-
-export default mergeConfig(
-  configShared,
-  defineConfig({
-    test: {
-      include: [
-        '**/*.integ.ts',
-      ],
-      setupFiles: [
-        './test-with-vite/setup/mongoms.ts',
-      ],
-      coverage: {
-        reportsDirectory: './coverage/integ',
-        exclude: [
-          '**/*{.,-}integ.ts',
-        ],
-      },
-    },
-  }),
-);

+ 0 - 19
apps/app/vitest.config.ts

@@ -1,19 +0,0 @@
-import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
-
-export default defineConfig({
-  plugins: [
-    tsconfigPaths(),
-  ],
-  test: {
-    environment: 'node',
-    exclude: [
-      '**/test/**', '**/*.spec.{tsx,jsx}',
-    ],
-    clearMocks: true,
-    globals: true,
-    coverage: {
-      reportsDirectory: './coverage/unit',
-    },
-  },
-});

+ 65 - 0
apps/app/vitest.workspace.mts

@@ -0,0 +1,65 @@
+import react from '@vitejs/plugin-react';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import {
+  defineConfig, defineWorkspace, mergeConfig,
+} from 'vitest/config';
+
+const configShared = defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    clearMocks: true,
+    globals: true,
+    exclude: [
+      'test/**',
+      'test-with-vite/**',
+      'playwright/**',
+    ]
+  },
+});
+
+export default defineWorkspace([
+
+  // unit test
+  mergeConfig(
+    configShared,
+    {
+      test: {
+        name: 'app-unit',
+        environment: 'node',
+        include: ['**/*.spec.{ts,js}'],
+      },
+    },
+  ),
+
+  // integration test
+  mergeConfig(
+    configShared,
+    {
+      test: {
+        name: 'app-integration',
+        environment: 'node',
+        include: ['**/*.integ.ts'],
+        setupFiles: [
+          './test-with-vite/setup/mongoms.ts',
+        ],
+      },
+    },
+  ),
+
+  // component test
+  mergeConfig(
+    configShared,
+    {
+      plugins: [react()],
+      test: {
+        name: 'app-components',
+        environment: 'happy-dom',
+        include: [
+          '**/*.spec.{tsx,jsx}',
+        ],
+      },
+    },
+  ),
+]);

+ 7 - 7
package.json

@@ -52,7 +52,7 @@
     "yargs": "^17.7.1"
   },
   "// comments for defDependencies": {
-    "vitest": "v1.6.0 occures an error on ci-app-test: \"ENOENT: no such file or directory, lstat '/home/runner/work/growi/growi/apps/app/coverage/.tmp'\""
+    "vite-plugin-dts": "v4.2.1 causes the unexpected error 'Cannot find package 'vue-tsc''"
   },
   "devDependencies": {
     "@changesets/changelog-github": "^0.5.0",
@@ -69,8 +69,8 @@
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^4.3.1",
-    "@vitest/coverage-v8": "^1.6.0",
-    "@vitest/ui": "^1.6.0",
+    "@vitest/coverage-v8": "^2.1.1",
+    "@vitest/ui": "^2.1.1",
     "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",
@@ -101,10 +101,10 @@
     "tsconfig-paths": "^4.2.0",
     "typescript": "~5.0.0",
     "typescript-transform-paths": "^3.4.7",
-    "vite": "^5.2.14",
-    "vite-plugin-dts": "^3.8.3",
-    "vite-tsconfig-paths": "^4.3.2",
-    "vitest": "~1.6.0",
+    "vite": "^5.4.6",
+    "vite-plugin-dts": "^3.9.1",
+    "vite-tsconfig-paths": "^5.0.1",
+    "vitest": "^2.1.1",
     "vitest-mock-extended": "^1.3.1"
   },
   "engines": {

+ 81 - 0
packages/pluginkit/src/v4/utils/template.spec.ts

@@ -0,0 +1,81 @@
+import type { TemplateSummary } from '../interfaces';
+
+import { getLocalizedTemplate, extractSupportedLocales } from './template';
+
+describe('getLocalizedTemplate', () => {
+  it('should return undefined if templateSummary is undefined', () => {
+    expect(getLocalizedTemplate(undefined)).toBeUndefined();
+  });
+
+  it('should return the default template if locale is not provided', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary)).toEqual(templateSummary.default);
+  });
+
+  it('should return the localized template if locale is provided and exists in templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+      ja_JP: {
+        id: 'templateId',
+        locale: 'ja_JP',
+        isValid: true,
+        isDefault: false,
+        title: 'Japanese Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary, 'ja_JP')).toEqual(templateSummary.ja_JP);
+  });
+
+  it('should return the default template if locale is provided but does not exist in templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary, 'fr')).toEqual(templateSummary.default);
+  });
+});
+
+describe('extractSupportedLocales', () => {
+  it('should return undefined if templateSummary is undefined', () => {
+    expect(extractSupportedLocales(undefined)).toBeUndefined();
+  });
+
+  it('should return a set of locales from the templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+      ja_JP: {
+        id: 'templateId',
+        locale: 'ja_JP',
+        isValid: true,
+        isDefault: false,
+        title: 'Japanese Title',
+      },
+    };
+    expect(extractSupportedLocales(templateSummary)).toEqual(new Set(['en_US', 'ja_JP']));
+  });
+});

+ 10 - 5
packages/pluginkit/vitest.config.ts

@@ -1,5 +1,5 @@
 import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
+import { defineConfig, coverageConfigDefaults } from 'vitest/config';
 
 export default defineConfig({
   plugins: [
@@ -10,11 +10,16 @@ export default defineConfig({
     clearMocks: true,
     globals: true,
     coverage: {
+      exclude: [
+        ...coverageConfigDefaults.exclude,
+        'src/v4/interfaces/**',
+        'src/**/index.ts',
+      ],
       thresholds: {
-        statements: 42.78,
-        branches: 63.15,
-        lines: 42.78,
-        functions: 26.31,
+        statements: 47.59,
+        branches: 89.47,
+        lines: 47.59,
+        functions: 66.66,
       },
     },
   },

+ 2 - 0
vitest.workspace.ts → vitest.workspace.mts

@@ -1,4 +1,6 @@
 export default [
   'apps/*/vitest.config.ts',
+  'apps/*/vitest.workspace.ts',
   'packages/*/vitest.config.ts',
+  'packages/*/vitest.workspace.ts',
 ];

Разлика између датотеке није приказан због своје велике величине
+ 448 - 367
yarn.lock


Неке датотеке нису приказане због велике количине промена