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

Merge branch 'master' into imprv/integrate-convert-to-useTranslation

ryoji-s пре 3 година
родитељ
комит
9ae04b7d73

+ 1 - 3
packages/app/src/client/app.jsx

@@ -14,7 +14,6 @@ import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-import TagContainer from '~/client/services/TagContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
@@ -66,11 +65,10 @@ const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContaine
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
-const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer,
+  commentContainer, editorContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');

+ 0 - 7
packages/app/src/client/services/EditorContainer.js

@@ -59,13 +59,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-  getCurrentOptionsToSave() {
-    const opt = {
-      pageTags: this.state.tags,
-    };
-
-    return opt;
-  }
 
   // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
   showUnsavedWarning(e) {

+ 0 - 69
packages/app/src/client/services/TagContainer.js

@@ -1,69 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiGet } from '../util/apiv1-client';
-
-const logger = loggerFactory('growi:services:TagContainer');
-
-/**
- * Service container related to Tag
- * @extends {Container} unstated Container
- */
-export default class TagContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    this.init();
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'TagContainer';
-  }
-
-  /**
-   * retrieve tags data
-   * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
-   */
-  async init() {
-    const pageContainer = this.appContainer.getContainer('PageContainer');
-    const editorContainer = this.appContainer.getContainer('EditorContainer');
-
-    if (Object.keys(pageContainer.state).length === 0) {
-      logger.debug('There is no need to initialize TagContainer because this is not a Page');
-      return;
-    }
-
-    const { pageId, templateTagData, shareLinkId } = pageContainer.state;
-
-    if (shareLinkId != null) {
-      return;
-    }
-
-    let tags = [];
-    // when the page exists or shared page
-    if (pageId != null && shareLinkId == null) {
-      const res = await apiGet('/pages.getPageTag', { pageId });
-      tags = res.tags;
-    }
-    // when the page not exist
-    else if (templateTagData != null) {
-      tags = templateTagData.split(',').filter((str) => {
-        return str !== ''; // filter empty values
-      });
-    }
-
-    logger.debug('tags data has been initialized');
-
-    pageContainer.setState({ tags });
-    editorContainer.setState({ tags });
-  }
-
-}

+ 9 - 9
packages/app/src/client/util/apiv1-client.ts

@@ -30,7 +30,7 @@ class Apiv1ErrorHandler extends Error {
 
 }
 
-export async function apiRequest(method: string, path: string, params: unknown): Promise<unknown> {
+export async function apiRequest<T>(method: string, path: string, params: unknown): Promise<T> {
   const res = await axios[method](urljoin(apiv1Root, path), params);
 
   if (res.data.ok) {
@@ -46,29 +46,29 @@ export async function apiRequest(method: string, path: string, params: unknown):
   throw new Error(res.data.error);
 }
 
-export async function apiGet(path: string, params: unknown = {}): Promise<unknown> {
-  return apiRequest('get', path, { params });
+export async function apiGet<T>(path: string, params: unknown = {}): Promise<T> {
+  return apiRequest<T>('get', path, { params });
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function apiPost(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
+export async function apiPost<T>(path: string, params: any & ParamWithCsrfKey = {}): Promise<T> {
   if (params._csrf == null) {
     params._csrf = csrfToken;
   }
-  return apiRequest('post', path, params);
+  return apiRequest<T>('post', path, params);
 }
 
-export async function apiPostForm(path: string, formData: FormData): Promise<unknown> {
+export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
   if (formData.get('_csrf') == null && csrfToken != null) {
     formData.append('_csrf', csrfToken);
   }
-  return apiPost(path, formData);
+  return apiPost<T>(path, formData);
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
+export async function apiDelete<T>(path: string, params: any & ParamWithCsrfKey = {}): Promise<T> {
   if (params._csrf == null) {
     params._csrf = csrfToken;
   }
-  return apiRequest('delete', path, { data: params });
+  return apiRequest<T>('delete', path, { data: params });
 }

+ 2 - 6
packages/app/src/client/util/editor.ts

@@ -1,5 +1,3 @@
-import EditorContainer from '~/client/services/EditorContainer';
-
 type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
@@ -9,18 +7,16 @@ type OptionsToSave = {
   grantUserGroupName?: string | null;
 };
 
-// TODO: Remove editorContainer upon migration to SWR
 export const getOptionsToSave = (
     isSlackEnabled: boolean,
     slackChannels: string,
     grant: number,
     grantUserGroupId: string | null | undefined,
     grantUserGroupName: string | null | undefined,
-    editorContainer: EditorContainer,
+    pageTags: string[],
 ): OptionsToSave => {
-  const optionsToSave = editorContainer.getCurrentOptionsToSave();
   return {
-    ...optionsToSave,
+    pageTags,
     isSlackEnabled,
     slackChannels,
     grant,

+ 28 - 18
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
@@ -8,17 +8,20 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
-import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
+import {
+  IPageHasId, IPageToRenameWithMeta, IPageWithMeta,
+} from '~/interfaces/page';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
+import { usePageTagsForEditors } from '~/stores/editor';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
-import { useSWRTagsInfo } from '~/stores/page';
+import { useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
@@ -165,41 +168,48 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(pageId);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
+  useEffect(() => {
+    // Run only when tagsInfoData has been updated
+    syncPageTagsForEditors();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [tagsInfoData?.tags]);
+
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
   const {
-    editorContainer, isCompactMode, isLinkSharingDisabled,
+    isCompactMode, isLinkSharingDisabled,
   } = props;
 
   const isViewMode = editorMode === EditorMode.View;
 
-  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === EditorMode.Editor) {
-      return editorContainer.setState({ tags: newTags });
-    }
-
+  const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
     try {
-      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
+      await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
 
       // revalidate SWRTagsInfo
       mutateSWRTagsInfo();
-      // update editorContainer.state
-      editorContainer.setState({ tags });
+      mutatePageTagsForEditors(newTags);
 
       toastSuccess('updated tags successfully');
     }
     catch (err) {
       toastError(err, 'fail to update tags');
     }
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [pageId]);
+
+  }, [pageId, revisionId, mutateSWRTagsInfo, mutatePageTagsForEditors]);
+
+  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) => {
@@ -322,8 +332,8 @@ const GrowiContextualSubNavigation = (props) => {
       isGuestUser={isGuestUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
-      tags={tagsInfoData?.tags || []}
-      tagsUpdatedHandler={tagsUpdatedHandler}
+      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
       controls={ControlComponents}
       additionalClasses={['container-fluid']}
     />

+ 3 - 2
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -25,7 +25,7 @@ type Props = {
   isCompactMode?: boolean,
 
   tags?: string[],
-  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
 
   controls?: React.FunctionComponent,
   additionalClasses?: string[],
@@ -71,7 +71,8 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
         ) }
 
         <div className="grw-path-nav-container">
-          { showTagLabel && !isCompactMode && (
+          {/* "/trash" page does not exist on page collection and unable to add tags  */}
+          { showTagLabel && !isCompactMode && path !== '/trash' && (
             <div className="grw-taglabels-container">
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>

+ 8 - 5
packages/app/src/components/Page.jsx

@@ -11,7 +11,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 import {
   useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -76,9 +76,9 @@ class Page extends React.Component {
 
   async saveHandlerForHandsontableModal(markdownTable) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
@@ -108,9 +108,9 @@ class Page extends React.Component {
 
   async saveHandlerForDrawioModal(drawioData) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
+      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, editorContainer,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
     const newMarkdown = mdu.replaceDrawioInMarkdown(
       drawioData,
@@ -172,6 +172,7 @@ Page.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   pagePath: PropTypes.string.isRequired,
+  pageTags:  PropTypes.arrayOf(PropTypes.string),
   editorMode: PropTypes.string.isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isMobile: PropTypes.bool,
@@ -189,6 +190,7 @@ const PageWrapper = (props) => {
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: pageTags } = usePageTagsForEditors();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
@@ -237,6 +239,7 @@ const PageWrapper = (props) => {
       isGuestUser={isGuestUser}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
+      pageTags={pageTags}
       slackChannels={slackChannelsData.toString()}
       grant={grant}
       grantGroupId={grantGroupId}

+ 1 - 1
packages/app/src/components/Page/TagLabels.tsx

@@ -6,7 +6,7 @@ import TagEditModal from './TagEditModal';
 type Props = {
   tags?: string[],
   isGuestUser: boolean,
-  tagsUpdateInvoked?: (tags: string[]) => Promise<void>,
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 
 

+ 14 - 18
packages/app/src/components/Page/TagsInput.tsx

@@ -1,11 +1,10 @@
 import React, {
   FC, useRef, useState, useCallback,
 } from 'react';
+
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
-import { apiGet } from '~/client/util/apiv1-client';
-import { toastError } from '~/client/util/apiNotification';
-import { IResTagsSearchApiv1 } from '~/interfaces/tag';
+import { useSWRxTagsSearch } from '~/stores/tag';
 
 type TypeaheadInstance = {
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
@@ -24,7 +23,11 @@ const TagsInput: FC<Props> = (props: Props) => {
   const tagsInputRef = useRef<TypeaheadInstance>(null);
 
   const [resultTags, setResultTags] = useState<string[]>([]);
-  const [isLoading, setLoading] = useState(false);
+  const [searchQuery, setSearchQuery] = useState('');
+
+  const { data: tagsSearch, error } = useSWRxTagsSearch(searchQuery);
+
+  const isLoading = error == null && tagsSearch === undefined;
 
   const changeHandler = useCallback((selected: string[]) => {
     if (props.onTagsUpdated != null) {
@@ -33,20 +36,13 @@ const TagsInput: FC<Props> = (props: Props) => {
   }, [props]);
 
   const searchHandler = useCallback(async(query: string) => {
-    setLoading(true);
-    try {
-      // TODO: 91698 SWRize
-      const res = await apiGet('/tags.search', { q: query }) as IResTagsSearchApiv1;
-      res.tags.unshift(query);
-      setResultTags(Array.from(new Set(res.tags)));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setLoading(false);
-    }
-  }, []);
+    const tagsSearchData = tagsSearch?.tags || [];
+    setSearchQuery(query);
+
+    tagsSearchData.unshift(searchQuery);
+    setResultTags(Array.from(new Set(tagsSearchData)));
+
+  }, [searchQuery, tagsSearch?.tags]);
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {

+ 8 - 4
packages/app/src/components/PageEditor.tsx

@@ -13,9 +13,11 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
-import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
 import {
-  useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled,
+  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId,
+} from '~/stores/context';
+import {
+  useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
 } from '~/stores/editor';
 import {
   EditorMode,
@@ -84,6 +86,8 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: editorMode } = useEditorMode();
   const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
@@ -121,7 +125,7 @@ const PageEditor = (props: Props): JSX.Element => {
 
     const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
 
-    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, pageTags || []);
 
     try {
       // disable unsaved warning
@@ -140,7 +144,7 @@ const PageEditor = (props: Props): JSX.Element => {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
     }
-  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer]);
+  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer, pageTags]);
 
 
   /**

+ 9 - 5
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React from 'react';
 
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
@@ -9,8 +9,8 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
-import { useCurrentPagePath } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -172,9 +172,9 @@ class PageEditorByHackmd extends React.Component {
    */
   async onSaveWithShortcut(markdown) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
     try {
       // disable unsaved warning
@@ -434,6 +434,7 @@ PageEditorByHackmd.propTypes = {
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
+  pageTags: PropTypes.arrayOf(PropTypes.string),
   slackChannels: PropTypes.string.isRequired,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
@@ -451,6 +452,8 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
@@ -466,6 +469,7 @@ const PageEditorByHackmdWrapper = (props) => {
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannelsData.toString()}
+      pageTags={pageTags}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}

+ 10 - 5
packages/app/src/components/SavePageControls.jsx

@@ -14,7 +14,8 @@ import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import { useIsEditable } from '~/stores/context';
+import { useIsEditable, useCurrentPageId } from '~/stores/context';
+import { usePageTagsForEditors } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -50,14 +51,14 @@ class SavePageControls extends React.Component {
 
   async save() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
     } = this.props;
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
 
     try {
       // save
-      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
       await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     catch (error) {
@@ -76,12 +77,12 @@ class SavePageControls extends React.Component {
 
   saveAndOverwriteScopesOfDescendants() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
     } = this.props;
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     // save
-    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
     });
@@ -143,6 +144,8 @@ const SavePageControlsWrapper = (props) => {
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags } = usePageTagsForEditors(pageId);
 
 
   if (isEditable == null || editorMode == null) {
@@ -164,6 +167,7 @@ const SavePageControlsWrapper = (props) => {
       mutateGrant={mutateGrant}
       mutateGrantGroupId={mutateGrantGroupId}
       mutateGrantGroupName={mutateGrantGroupName}
+      pageTags={pageTags}
     />
   );
 };
@@ -179,6 +183,7 @@ SavePageControls.propTypes = {
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
+  pageTags: PropTypes.arrayOf(PropTypes.string),
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,

+ 0 - 3
packages/app/src/interfaces/pageTagsInfo.ts

@@ -1,3 +0,0 @@
-export type IPageTagsInfo = {
-  tags : string[],
-}

+ 12 - 0
packages/app/src/interfaces/tag.ts

@@ -6,11 +6,23 @@ export type ITag<ID = string> = {
 export type IDataTagCount = ITag & {count: number}
 
 
+export type IPageTagsInfo = {
+  tags : string[],
+}
+
+export type IListTagNamesByPage = string[];
+
+
 export type IResTagsSearchApiv1 = {
   ok: boolean,
   tags: string[]
 }
 
+export type IResGetPageTags = {
+  ok: boolean,
+  tags: string[],
+};
+
 export type IResTagsListApiv1 = {
   ok: boolean,
   data: IDataTagCount[],

+ 4 - 2
packages/app/src/server/service/file-uploader/gcs.js

@@ -2,8 +2,8 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
-const urljoin = require('url-join');
 const { Storage } = require('@google-cloud/storage');
+const urljoin = require('url-join');
 
 let _instance;
 
@@ -21,7 +21,9 @@ module.exports = function(crowi) {
     if (_instance == null) {
       const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
       // see https://googleapis.dev/nodejs/storage/latest/Storage.html
-      _instance = new Storage({ keyFilename });
+      _instance = keyFilename != null
+        ? new Storage({ keyFilename }) // Create a client with explicit credentials
+        : new Storage(); // Create a client that uses Application Default Credentials
     }
     return _instance;
   }

+ 23 - 12
packages/app/src/server/service/passport.ts

@@ -638,7 +638,7 @@ class PassportService implements S2sMessageHandlable {
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
 
     // Prevent request timeout error on app init
-    const oidcIssuer = await this.getOIDCIssuerInstace(issuerHost);
+    const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (oidcIssuer != null) {
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
@@ -719,16 +719,26 @@ class PassportService implements S2sMessageHandlable {
 
   /**
    * Sanitize issuer Host / URL to match specified format
-   * Acceptable format : eg. https://hostname.com
+   * Acceptable formats :
+   * - https://hostname.com/auth/
+   * - domain only (hostname.com)
+   * - Full metadata url (https://hostname.com/auth/v2/.well-known/openid-configuration)
    * @param issuerHost string
-   * @returns string URL.origin
+   * @returns string URL/.well-known/openid-configuration
    */
-  getOIDCIssuerHostName(issuerHost) {
+  getOIDCMetadataURL(issuerHost: string) : string {
     const protocol = 'https://';
     const pattern = /^https?:\/\//i;
+    const metadataPath = '/.well-known/openid-configuration';
+    // If URL is full path with .well-known/openid-configuration
+    if (issuerHost.endsWith(metadataPath)) {
+      return issuerHost;
+    }
     // Set protocol if not available on url
     const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
-    return new URL(absUrl).origin;
+    const url = new URL(absUrl).href;
+    // Remove trailing slash if exists
+    return `${url.replace(/\/+$/, '')}${metadataPath}`;
   }
 
   /**
@@ -736,17 +746,17 @@ class PassportService implements S2sMessageHandlable {
  * Check and initialize connection to OIDC issuer host
  * Prevent request timeout error on app init
  *
- * @param issuerHost
+ * @param issuerHost string
  * @returns boolean
  */
-  async isOidcHostReachable(issuerHost) {
+  async isOidcHostReachable(issuerHost: string): Promise<boolean | undefined> {
     try {
-      const hostname = this.getOIDCIssuerHostName(issuerHost);
+      const metadataUrl = this.getOIDCMetadataURL(issuerHost);
       const client = require('axios').default;
       axiosRetry(client, {
         retries: 3,
       });
-      const response = await client.get(`${hostname}/.well-known/openid-configuration`);
+      const response = await client.get(metadataUrl);
       // Check for valid OIDC Issuer configuration
       if (!response.data.issuer) {
         logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
@@ -763,10 +773,10 @@ class PassportService implements S2sMessageHandlable {
    * Get oidcIssuer object
    * Utilize p-retry package to retry oidcIssuer initialization 3 times
    *
-   * @param issuerHost
+   * @param issuerHost string
    * @returns instance of OIDCIssuer
    */
-  async getOIDCIssuerInstace(issuerHost) {
+  async getOIDCIssuerInstance(issuerHost: string): Promise<void | OIDCIssuer> {
     const OIDC_TIMEOUT_MULTIPLIER = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:timeoutMultiplier');
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
@@ -775,8 +785,9 @@ class PassportService implements S2sMessageHandlable {
       logger.error('OidcStrategy: setup failed');
       return;
     }
+    const metadataURL = this.getOIDCMetadataURL(issuerHost);
     const oidcIssuer = await pRetry(async() => {
-      return OIDCIssuer.discover(issuerHost);
+      return OIDCIssuer.discover(metadataURL);
     }, {
       onFailedAttempt: (error) => {
         // get current OIDCIssuer timeout options

+ 18 - 0
packages/app/src/stores/editor.tsx

@@ -11,6 +11,7 @@ import {
   useCurrentUser, useDefaultIndentSize, useIsGuestUser,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
+import { useSWRxTagsInfo } from './page';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -90,3 +91,20 @@ export const useIsSlackEnabled = (): SWRResponse<boolean, Error> => {
     { fallbackData: false },
   );
 };
+
+export type IPageTagsForEditorsOption = {
+  sync: (tags?: string[]) => void;
+}
+
+export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<string[], Error> & IPageTagsForEditorsOption => {
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const swrResult = useStaticSWR<string[], Error>('pageTags', undefined);
+
+  return {
+    ...swrResult,
+    sync: (): void => {
+      const { mutate } = swrResult;
+      mutate(tagsInfoData?.tags || [], false);
+    },
+  };
+};

+ 3 - 2
packages/app/src/stores/page.tsx

@@ -11,7 +11,8 @@ import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page
 import { IPagingResult } from '~/interfaces/paging-result';
 
 import { apiGet } from '../client/util/apiv1-client';
-import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
+import { Nullable } from '../interfaces/common';
+import { IPageTagsInfo } from '../interfaces/tag';
 
 import { useCurrentPageId, useCurrentPagePath } from './context';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
@@ -91,7 +92,7 @@ export const useSWRxDescendantsPageListForCurrrentPath = (pageNumber?: number):
   return useSWRxPageList(path, pageNumber, termNumber);
 };
 
-export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<IPageTagsInfo, Error> => {
+export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo, Error> => {
   const key = pageId == null ? null : `/pages.getPageTag?pageId=${pageId}`;
 
   return useSWRImmutable(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {

+ 8 - 1
packages/app/src/stores/tag.tsx

@@ -2,7 +2,7 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
-import { IResTagsListApiv1 } from '~/interfaces/tag';
+import { IResTagsListApiv1, IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
   return useSWRImmutable(
@@ -10,3 +10,10 @@ export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IR
     (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
   );
 };
+
+export const useSWRxTagsSearch = (query: string): SWRResponse<IResTagsSearchApiv1, Error> => {
+  return useSWRImmutable(
+    ['/tags.search', query],
+    (endpoint, query) => apiGet(endpoint, { q: query }).then((result: IResTagsSearchApiv1) => result),
+  );
+};

+ 1 - 1
packages/app/src/styles/_subnav.scss

@@ -58,7 +58,7 @@
   .total-likes,
   .total-bookmarks {
     display: flex;
-    align-items: end;
+    align-items: flex-end;
     padding-right: 8px;
     padding-left: 6px;
     font-size: 14px;