Просмотр исходного кода

Merge pull request #8167 from weseek/imprv/132486-able-to-edit-tags-in-editor

imprv: Able to edit tags in editor
Ryoji Shimizu 2 лет назад
Родитель
Сommit
2c93e596a6

+ 0 - 32
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -202,12 +202,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // eslint-disable-next-line max-len
-  // const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
-  // const { data: templateTagData } = useTemplateTagData();
-
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -217,36 +211,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const grant = currentPage?.grant ?? grantData?.grant;
   const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   // Run only when tagsInfoData has been updated
-  //   if (templateTagData == null) {
-  //     syncPageTagsForEditors();
-  //   }
-  //   // eslint-disable-next-line react-hooks/exhaustive-deps
-  // }, [tagsInfoData?.tags]);
-
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   if (pageId === null && templateTagData != null) {
-  //     mutatePageTagsForEditors(templateTagData);
-  //   }
-  // }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
-
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
   const { isLinkSharingDisabled } = props;
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
-  //   // It will not be reflected in the DB until the page is refreshed
-  //   mutatePageTagsForEditors(newTags);
-  //   return;
-  // }, [mutatePageTagsForEditors]);
-
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);

+ 44 - 4
apps/app/src/components/PageControls/PageControls.tsx

@@ -14,9 +14,10 @@ import {
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import type { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
@@ -31,6 +32,26 @@ import SubscribeButton from './SubscribeButton';
 
 import styles from './PageControls.module.scss';
 
+type TagsProps = {
+  onClickEditTagsButton: () => void,
+}
+
+const Tags = (props: TagsProps): JSX.Element => {
+  const { onClickEditTagsButton } = props;
+
+  return (
+    <div className="grw-taglabels-container d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
+        onClick={onClickEditTagsButton}
+      >
+        <i className="icon-tag me-2" />
+        Tags
+      </button>
+    </div>
+  );
+};
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
   onClickMenuItem: (newValue: boolean) => void,
@@ -84,6 +105,7 @@ type PageControlsSubstanceProps = CommonProps & {
   path?: string | null,
   pageInfo: IPageInfoForOperation,
   expandContentWidth?: boolean,
+  onClickEditTagsButton: () => void,
 }
 
 const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element => {
@@ -91,11 +113,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     pageInfo,
     pageId, revisionId, path, shareLinkId, expandContentWidth,
     disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: editorMode } = useEditorMode();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -214,8 +237,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     MenuItemType.REVERT,
   ];
 
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+      {revisionId != null && !isViewMode && (
+        <Tags
+          onClickEditTagsButton={onClickEditTagsButton}
+        />
+      )}
       {revisionId != null && (
         <SubscribeButton
           status={pageInfo.subscriptionStatus}
@@ -266,7 +296,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId?: string | null,
+  revisionId?: string,
   path?: string | null,
   expandContentWidth?: boolean,
 };
@@ -278,6 +308,15 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { open: openTagEditModal } = useTagEditModal();
+
+  const onClickEditTagsButton = useCallback(() => {
+    if (tagsInfoData == null || revisionId == null) {
+      return;
+    }
+    openTagEditModal(tagsInfoData.tags, pageId, revisionId);
+  }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
   if (error != null) {
     return <></>;
@@ -294,6 +333,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
       pageId={pageId}
       revisionId={revisionId ?? null}
       path={path}
+      onClickEditTagsButton={onClickEditTagsButton}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}

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

@@ -6,11 +6,8 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 
-import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { apiPost } from '~/client/util/apiv1-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import { useDescendantsPageListModal } from '~/stores/modal';
+import { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
 
@@ -45,22 +42,14 @@ const Tags = (props: TagsProps): JSX.Element => {
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { open: openTagEditModal } = useTagEditModal();
 
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
-
-  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
-    try {
-      await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
-
-      updateStateAfterSave?.();
-
-      toastSuccess('updated tags successfully');
+  const onClickEditTagsButton = useCallback(() => {
+    if (tagsInfoData == null) {
+      return;
     }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [pageId, revisionId, updateStateAfterSave]);
+    openTagEditModal(tagsInfoData.tags, pageId, revisionId);
+  }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
   if (!showTagLabel) {
     return <></>;
@@ -71,7 +60,13 @@ const Tags = (props: TagsProps): JSX.Element => {
   return (
     <div className="grw-taglabels-container">
       { tagsInfoData?.tags != null
-        ? <PageTags tags={tagsInfoData.tags} isTagLabelsDisabled={isTagLabelsDisabled ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+        ? (
+          <PageTags
+            tags={tagsInfoData.tags}
+            isTagLabelsDisabled={isTagLabelsDisabled}
+            onClickEditTagsButton={onClickEditTagsButton}
+          />
+        )
         : <PageTagsSkeleton />
       }
     </div>

+ 6 - 20
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,9 +1,8 @@
-import React, { FC, useState } from 'react';
+import React, { FC } from 'react';
 
 import { Skeleton } from '../Skeleton';
 
 import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
 
 import styles from './TagLabels.module.scss';
 
@@ -11,6 +10,7 @@ type Props = {
   tags?: string[],
   isTagLabelsDisabled: boolean,
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
+  onClickEditTagsButton: () => void,
 }
 
 export const PageTagsSkeleton = (): JSX.Element => {
@@ -18,17 +18,9 @@ export const PageTagsSkeleton = (): JSX.Element => {
 };
 
 export const PageTags:FC<Props> = (props: Props) => {
-  const { tags, isTagLabelsDisabled, tagsUpdateInvoked } = props;
-
-  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
-
-  const openEditorModal = () => {
-    setIsTagEditModalShown(true);
-  };
-
-  const closeEditorModal = () => {
-    setIsTagEditModalShown(false);
-  };
+  const {
+    tags, isTagLabelsDisabled, onClickEditTagsButton,
+  } = props;
 
   if (tags == null) {
     return <PageTagsSkeleton />;
@@ -41,16 +33,10 @@ export const PageTags:FC<Props> = (props: Props) => {
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center ${printNoneClass}`} data-testid="grw-tag-labels">
         <RenderTagLabels
           tags={tags}
-          openEditorModal={openEditorModal}
           isTagLabelsDisabled={isTagLabelsDisabled}
+          onClickEditTagsButton={onClickEditTagsButton}
         />
       </div>
-      <TagEditModal
-        tags={tags}
-        isOpen={isTagEditModalShown}
-        onClose={closeEditorModal}
-        onTagsUpdated={tagsUpdateInvoked}
-      />
     </>
   );
 };

+ 11 - 12
apps/app/src/components/PageTags/RenderTagLabels.tsx

@@ -10,22 +10,17 @@ import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 type RenderTagLabelsProps = {
   tags: string[],
   isTagLabelsDisabled: boolean,
-  openEditorModal?: () => void,
+  onClickEditTagsButton: () => void,
 }
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
-  const { tags, isTagLabelsDisabled, openEditorModal } = props;
+  const {
+    tags, isTagLabelsDisabled, onClickEditTagsButton,
+  } = props;
   const { t } = useTranslation();
 
   const { pushState } = useKeywordManager();
 
-  function openEditorHandler() {
-    if (openEditorModal == null) {
-      return;
-    }
-    openEditorModal();
-  }
-
   const isTagsEmpty = tags.length === 0;
 
   return (
@@ -46,10 +41,14 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
         <NotAvailableForReadOnlyUser>
           <div id="edit-tags-btn-wrapper-for-tooltip" className="d-print-none">
             <a
-              className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isTagLabelsDisabled && 'disabled'}`}
-              onClick={openEditorHandler}
+              className={
+                `btn btn-link btn-edit-tags text-muted d-flex align-items-center
+                ${isTagsEmpty && 'no-tags'}
+                ${isTagLabelsDisabled && 'disabled'}`
+              }
+              onClick={onClickEditTagsButton}
             >
-              { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+              {isTagsEmpty && <>{ t('Add tags for this page') }</>}
               <i className={`icon-plus ${isTagsEmpty && 'ms-1'}`} />
             </a>
           </div>

+ 48 - 27
apps/app/src/components/PageTags/TagEditModal.tsx

@@ -1,49 +1,62 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { apiPost } from '~/client/util/apiv1-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useTagEditModal, type TagEditModalStatus } from '~/stores/modal';
+
 import { TagsInput } from './TagsInput';
 
-type Props = {
-  tags: string[],
-  isOpen: boolean,
-  onClose?: () => void,
-  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
-};
+type TagEditModalSubstanceProps = {
+  tagEditModalData: TagEditModalStatus,
+  closeTagEditModal: () => void,
+}
 
-function TagEditModal(props: Props): JSX.Element {
-  const { onClose, onTagsUpdated } = props;
+const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagEditModalSubstanceProps) => {
+  const { tagEditModalData, closeTagEditModal } = props;
+  const { t } = useTranslation();
+
+  const initTags = tagEditModalData.tags;
+  const isOpen = tagEditModalData.isOpen;
+  const pageId = tagEditModalData.pageId;
+  const revisionId = tagEditModalData.revisionId;
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
   const [tags, setTags] = useState<string[]>([]);
-  const { t } = useTranslation();
 
+  // use to take initTags when redirect to other page
   useEffect(() => {
-    setTags(props.tags);
-  }, [props.tags]);
+    setTags(initTags);
+  }, [initTags]);
 
-  const closeModalHandler = useCallback(() => {
-    onClose?.();
-  }, [onClose]);
+  const handleSubmit = useCallback(async() => {
 
-  const handleSubmit = useCallback(() => {
-    if (onTagsUpdated == null) {
-      return;
-    }
+    try {
+      await apiPost('/tags.update', { pageId, revisionId, tags });
+      updateStateAfterSave?.();
 
-    onTagsUpdated(tags);
-    closeModalHandler();
-  }, [closeModalHandler, onTagsUpdated, tags]);
+      toastSuccess('updated tags successfully');
+      closeTagEditModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [closeTagEditModal, tags, pageId, revisionId, updateStateAfterSave]);
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
+    <Modal isOpen={isOpen} toggle={closeTagEditModal} id="edit-tag-modal" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeTagEditModal} className="bg-primary text-light">
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={tags} onTagsUpdated={tags => setTags(tags)} autoFocus />
+        <TagsInput tags={initTags} onTagsUpdated={tags => setTags(tags)} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-primary" onClick={handleSubmit}>
@@ -53,6 +66,14 @@ function TagEditModal(props: Props): JSX.Element {
     </Modal>
   );
 
-}
+};
 
-export default TagEditModal;
+export const TagEditModal: React.FC = () => {
+  const { data: tagEditModalData, close: closeTagEditModal } = useTagEditModal();
+
+  if (!tagEditModalData?.isOpen) {
+    return <></>;
+  }
+
+  return <TagEditModalSubstance tagEditModalData={tagEditModalData} closeTagEditModal={closeTagEditModal} />;
+};

+ 7 - 6
apps/app/src/components/PageTags/TagsInput.tsx

@@ -22,8 +22,9 @@ type Props = {
 
 export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const tagsInputRef = useRef<TypeaheadInstance>(null);
+  const { tags, autoFocus, onTagsUpdated } = props;
 
+  const tagsInputRef = useRef<TypeaheadInstance>(null);
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [searchQuery, setSearchQuery] = useState('');
 
@@ -32,10 +33,10 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const isLoading = error == null && tagsSearch === undefined;
 
   const changeHandler = useCallback((selected: string[]) => {
-    if (props.onTagsUpdated != null) {
-      props.onTagsUpdated(selected);
+    if (onTagsUpdated != null) {
+      onTagsUpdated(selected);
     }
-  }, [props]);
+  }, [onTagsUpdated]);
 
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
@@ -64,7 +65,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
         id="tag-typeahead-asynctypeahead"
         ref={tagsInputRef}
         caseSensitive={false}
-        defaultSelected={props.tags ?? []}
+        defaultSelected={tags}
         isLoading={isLoading}
         minLength={1}
         multiple
@@ -74,7 +75,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
         onKeyDown={keyDownHandler}
         options={resultTags} // Search result (Some tag names)
         placeholder={t('tag_edit_modal.tags_input.tag_name')}
-        autoFocus={props.autoFocus}
+        autoFocus={autoFocus}
       />
     </div>
   );

+ 2 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -76,6 +76,7 @@ const TemplateModal = dynamic(() => import('../components/TemplateModal').then(m
 const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditModal').then(mod => mod.LinkEditModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
+const TagEditModal = dynamic(() => import('../components/PageTags/TagEditModal').then(mod => mod.TagEditModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -376,6 +377,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <QuestionnaireModalManager />
       <TemplateModal />
       <LinkEditModal />
+      <TagEditModal />
     </>
   );
 };

+ 49 - 1
apps/app/src/stores/modal.tsx

@@ -700,7 +700,7 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
   const open = useCallback((attachment: IAttachmentHasId, remove: Remove) => {
     mutate({ isOpened: true, attachment, remove });
   }, [mutate]);
-  const close = useCallback((): void => {
+  const close = useCallback(() => {
     mutate({ isOpened: false });
   }, [mutate]);
 
@@ -773,3 +773,51 @@ export const usePageSelectModal = (
     close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
+
+/*
+* TagEditModal
+*/
+export type TagEditModalStatus = {
+  isOpen: boolean,
+  tags: string[],
+  pageId: string,
+  revisionId: string,
+}
+
+type TagEditModalUtils = {
+  open(tags: string[], pageId: string, revisionId: string): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const useTagEditModal = (): SWRResponse<TagEditModalStatus, Error> & TagEditModalUtils => {
+  const initialStatus: TagEditModalStatus = useMemo(() => {
+    return {
+      isOpen: false,
+      tags: [],
+      pageId: '',
+      revisionId: '',
+    };
+  }, []);
+
+  const swrResponse = useStaticSWR<TagEditModalStatus, Error>('TagEditModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
+
+  const open = useCallback(async(tags: string[], pageId: string, revisionId: string) => {
+    mutate({
+      isOpen: true,
+      tags,
+      pageId,
+      revisionId,
+    });
+  }, [mutate]);
+
+  const close = useCallback(async() => {
+    mutate(initialStatus);
+  }, [initialStatus, mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};