Răsfoiți Sursa

Merge pull request #8252 from weseek/feat/create-title-component-for-page-header

feat: create header for changing page title and path
Yuki Takei 2 ani în urmă
părinte
comite
0c90c2f9dc

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

@@ -819,5 +819,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "The attachment could not be found"
+  },
+  "page_select_modal": {
+    "select_page_location": "Select page location"
   }
 }

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

@@ -852,5 +852,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "アタッチメントが見つかりません"
+  },
+  "page_select_modal": {
+    "select_page_location": "ページの場所を選択"
   }
 }

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

@@ -822,5 +822,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "没有找到附件"
+  },
+  "page_select_modal": {
+    "select_page_location": "选择页面位置"
   }
 }

+ 3 - 0
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -12,6 +12,7 @@ type ClosableTextInputProps = {
   validationTarget?: string,
   onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
+  handleInputChange?: (string) => void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
@@ -38,6 +39,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     createValidation(inputText);
     setInputText(inputText);
     setIsAbleToShowAlert(true);
+
+    props.handleInputChange?.(inputText);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {

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

@@ -54,6 +54,7 @@ import { useNextThemes } from '~/stores/use-next-themes';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import { PageHeader } from '../PageHeader/PageHeader';
 
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
@@ -476,8 +477,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
-      <div className="flex-expand-vert justify-content-center align-items-center" style={{ minHeight: '72px' }}>
-        <div>Header</div>
+      <div className="flex-expand-vert justify-content-center" style={{ minHeight: '72px' }}>
+        <PageHeader />
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert">

+ 28 - 0
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -0,0 +1,28 @@
+import { FC } from 'react';
+
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+
+import { PagePathHeader } from './PagePathHeader';
+import { PageTitleHeader } from './PageTitleHeader';
+
+export const PageHeader: FC = () => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPage } = useSWRxCurrentPage();
+
+  if (currentPage == null || currentPagePath == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <PagePathHeader
+        currentPagePath={currentPagePath}
+        currentPage={currentPage}
+      />
+      <PageTitleHeader
+        currentPagePath={currentPagePath}
+        currentPage={currentPage}
+      />
+    </>
+  );
+};

+ 130 - 0
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -0,0 +1,130 @@
+import {
+  FC, useEffect, useMemo, useState,
+} from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+
+import { usePageSelectModal } from '~/stores/modal';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+import { PagePathNav } from '../Common/PagePathNav';
+import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
+
+import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { usePagePathRenameHandler } from './page-header-utils';
+
+type Props = {
+  currentPagePath: string
+  currentPage: IPagePopulatedToShowRevision
+}
+
+export const PagePathHeader: FC<Props> = (props) => {
+  const { currentPagePath, currentPage } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const [isButtonsShown, setButtonShown] = useState(false);
+  const [inputText, setInputText] = useState('');
+
+  const { data: editorMode } = useEditorMode();
+  const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
+
+  const onRenameFinish = () => {
+    setRenameInputShown(false);
+  };
+
+  const onRenameFailure = () => {
+    setRenameInputShown(true);
+  };
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+
+  const stateHandler = { isRenameInputShown, setRenameInputShown };
+
+  const isOpened = PageSelectModalData?.isOpened ?? false;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  const PagePath = useMemo(() => (
+    <>
+      {currentPagePath != null && (
+        <PagePathNav
+          pageId={currentPage._id}
+          pagePath={currentPagePath}
+          isSingleLineMode={isEditorMode}
+        />
+      )}
+    </>
+  ), [currentPage._id, currentPagePath, isEditorMode]);
+
+  const handleInputChange = (inputText: string) => {
+    setInputText(inputText);
+  };
+
+  const handleEditButtonClick = () => {
+    if (isRenameInputShown) {
+      pagePathRenameHandler(inputText);
+    }
+    else {
+      setRenameInputShown(true);
+    }
+  };
+
+  const buttonStyle = isButtonsShown ? '' : 'd-none';
+
+  const clickOutSideHandler = (e) => {
+    const container = document.getElementById('page-path-header');
+
+    if (container && !container.contains(e.target)) {
+      setRenameInputShown(false);
+    }
+  };
+
+  useEffect(() => {
+    document.addEventListener('click', clickOutSideHandler);
+
+    return () => {
+      document.removeEventListener('click', clickOutSideHandler);
+    };
+  }, []);
+
+  return (
+    <>
+      <div
+        id="page-path-header"
+        onMouseLeave={() => setButtonShown(false)}
+      >
+        <div className="row">
+          <div
+            className="col-4"
+            onMouseEnter={() => setButtonShown(true)}
+          >
+            <TextInputForPageTitleAndPath
+              currentPage={currentPage}
+              stateHandler={stateHandler}
+              inputValue={currentPagePath}
+              CustomComponent={PagePath}
+              handleInputChange={handleInputChange}
+            />
+          </div>
+          <div className={`${buttonStyle} col-4 row`}>
+            <div className="col-4">
+              <button type="button" onClick={handleEditButtonClick}>
+                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
+              </button>
+            </div>
+            <div className="col-4">
+              <button type="button" onClick={openPageSelectModal}>
+                <span className="material-symbols-outlined">account_tree</span>
+              </button>
+            </div>
+          </div>
+          {isOpened
+            && (
+              <PageSelectModal />
+            )}
+        </div>
+      </div>
+    </>
+  );
+};

+ 35 - 0
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -0,0 +1,35 @@
+import { FC, useState, useMemo } from 'react';
+
+import nodePath from 'path';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+
+import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+
+type Props = {
+  currentPagePath: string,
+  currentPage: IPagePopulatedToShowRevision;
+}
+
+
+export const PageTitleHeader: FC<Props> = (props) => {
+  const { currentPagePath, currentPage } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+
+  const stateHandler = { isRenameInputShown, setRenameInputShown };
+
+  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
+
+  return (
+    <div onBlur={() => setRenameInputShown(false)}>
+      <TextInputForPageTitleAndPath
+        currentPage={currentPage}
+        stateHandler={stateHandler}
+        inputValue={pageName}
+        CustomComponent={PageTitle}
+      />
+    </div>
+  );
+};

+ 76 - 0
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -0,0 +1,76 @@
+import { FC, useCallback } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+
+import nodePath from 'path';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+
+import { ValidationTarget } from '~/client/util/input-validator';
+
+import ClosableTextInput from '../Common/ClosableTextInput';
+
+
+import { usePagePathRenameHandler } from './page-header-utils';
+
+
+type StateHandler = {
+  isRenameInputShown: boolean
+  setRenameInputShown: Dispatch<SetStateAction<boolean>>
+}
+
+type Props = {
+  currentPage: IPagePopulatedToShowRevision
+  stateHandler: StateHandler
+  inputValue: string
+  CustomComponent: JSX.Element
+  handleInputChange?: (string) => void
+}
+
+export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
+  const {
+    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
+  } = props;
+
+  const { t } = useTranslation();
+
+  const { isRenameInputShown, setRenameInputShown } = stateHandler;
+
+  const onRenameFinish = () => {
+    setRenameInputShown(false);
+  };
+
+  const onRenameFailure = () => {
+    setRenameInputShown(true);
+  };
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+
+  const onPressEnter = useCallback((inputPagePath: string) => {
+
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
+
+    pagePathRenameHandler(newPagePath);
+
+  }, [currentPage.path, pagePathRenameHandler]);
+
+  return (
+    <>
+      {isRenameInputShown ? (
+        <div className="flex-fill">
+          <ClosableTextInput
+            value={inputValue}
+            placeholder={t('Input page name')}
+            onPressEnter={onPressEnter}
+            validationTarget={ValidationTarget.PAGE}
+            handleInputChange={handleInputChange}
+          />
+        </div>
+      ) : (
+        <>{ CustomComponent }</>
+      )}
+    </>
+  );
+};

+ 55 - 0
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -0,0 +1,55 @@
+import { useCallback } from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRMUTxCurrentPage } from '~/stores/page';
+import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+
+export const usePagePathRenameHandler = (
+    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
+): (newPagePath: string) => Promise<void> => {
+
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { t } = useTranslation();
+
+  const currentPagePath = currentPage.path;
+
+  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+
+    const onRenamed = (fromPath: string | undefined, toPath: string) => {
+      mutatePageTree();
+      mutatePageList();
+
+      if (currentPagePath === fromPath || currentPagePath === toPath) {
+        mutateCurrentPage();
+      }
+    };
+
+    if (newPagePath === currentPage.path || newPagePath === '') {
+      onRenameFinish?.();
+      return;
+    }
+
+    try {
+      onRenameFinish?.();
+      await apiv3Put('/pages/rename', {
+        pageId: currentPage._id,
+        revisionId: currentPage.revision._id,
+        newPagePath,
+      });
+
+      onRenamed(currentPage.path, newPagePath);
+
+      toastSuccess(t('renamed_pages', { path: currentPage.path }));
+    }
+    catch (err) {
+      onRenameFailure?.();
+      toastError(err);
+    }
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+
+  return pagePathRenameHandler;
+};

+ 8 - 9
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,5 +1,6 @@
-import React from 'react';
+import React, { FC } from 'react';
 
+import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
@@ -13,7 +14,7 @@ import { ItemsTree } from '../ItemsTree';
 import { TreeItemForModal } from './TreeItemForModal';
 
 
-export const PageSelectModal = () => {
+export const PageSelectModal: FC = () => {
   const {
     data: PageSelectModalData,
     close: closeModal,
@@ -21,6 +22,8 @@ export const PageSelectModal = () => {
 
   const isOpened = PageSelectModalData?.isOpened ?? false;
 
+  const { t } = useTranslation();
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
@@ -40,8 +43,9 @@ export const PageSelectModal = () => {
       isOpen={isOpened}
       toggle={() => closeModal()}
       centered
+      size="sm"
     >
-      <ModalHeader toggle={() => closeModal()}>modal</ModalHeader>
+      <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody>
         <ItemsTree
           CustomTreeItem={TreeItemForModal}
@@ -53,12 +57,7 @@ export const PageSelectModal = () => {
         />
       </ModalBody>
       <ModalFooter>
-        <Button color="primary">
-          Do Something
-        </Button>{' '}
-        <Button color="secondary">
-          Cancel
-        </Button>
+        <Button color="primary" onClick={closeModal}>{t('Done')}</Button>{' '}
       </ModalFooter>
     </Modal>
   );