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

Merge branch 'master' into dev/7.0.x

Yuki Takei 2 лет назад
Родитель
Сommit
18e9033254

+ 1 - 1
.github/ISSUE_TEMPLATE/config.yml

@@ -4,5 +4,5 @@ contact_links:
     url: https://github.com/weseek/growi/discussions
     url: https://github.com/weseek/growi/discussions
     about: If you have feature requests or suggestions, you can create a new discussion and consider it with the community.
     about: If you have feature requests or suggestions, you can create a new discussion and consider it with the community.
   - name: Questions
   - name: Questions
-    url: https://growi-slackin.weseek.co.jp/
+    url: https://communityinviter.com/apps/wsgrowi/invite/
     about: If you have questions, you can join our Slack team and talk about anything, anytime.
     about: If you have questions, you can join our Slack team and talk about anything, anytime.

+ 19 - 0
CHANGELOG.md

@@ -4,6 +4,25 @@
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.1.15](https://github.com/weseek/growi/compare/v6.1.14...v6.1.15) - 2023-09-11
+
+### 🚀 Improvement
+
+- imprv: Add CSP style-src for Safari and Content-Disposition of attachment (for v6.1.x) (#8057) @yuki-takei
+
+## [v6.1.14](https://github.com/weseek/growi/compare/v6.1.13...v6.1.14) - 2023-08-22
+
+### 🐛 Bug Fixes
+
+- fix: Add option to lightbox (6.1.x) (#8003) @yuki-takei
+
+## [v6.1.13](https://github.com/weseek/growi/compare/v6.1.12...v6.1.13) - 2023-08-17
+
+### 🐛 Bug Fixes
+
+- fix: Do not work img tag if use style property (#7988) @jam411
+- fix: "Searching..." label appearing unnecessarily (#7990) @yuki-takei
+
 ## [v6.1.12](https://github.com/weseek/growi/compare/v6.1.11...v6.1.12) - 2023-08-14
 ## [v6.1.12](https://github.com/weseek/growi/compare/v6.1.11...v6.1.12) - 2023-08-14
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 2 - 2
README.md

@@ -7,7 +7,7 @@
 </p>
 </p>
 <p align="center">
 <p align="center">
   <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
   <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-  <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
 </p>
 </p>
 
 
 <p align="center">
 <p align="center">
@@ -132,7 +132,7 @@ You can write issues and PRs in English or Japanese.
 
 
 ## Discussion
 ## Discussion
 
 
-If you have questions or suggestions, you can [join our Slack team](https://growi-slackin.weseek.co.jp/) and talk about anything, anytime.
+If you have questions or suggestions, you can [join our Slack team](https://communityinviter.com/apps/wsgrowi/invite/) and talk about anything, anytime.
 
 
 # License
 # License
 
 

+ 2 - 2
README_JP.md

@@ -6,7 +6,7 @@
   </p>
   </p>
   <p align="center">
   <p align="center">
     <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
     <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-    <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+    <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
   </p>
   </p>
 
 
 <p align="center">
 <p align="center">
@@ -129,7 +129,7 @@ Issue と Pull requests の作成は英語・日本語どちらでも受け付
 
 
 ## GROWI について話し合いましょう!
 ## GROWI について話し合いましょう!
 
 
-質問や提案があれば、私たちの [Slack team](https://growi-slackin.weseek.co.jp/) にぜひご参加ください。
+質問や提案があれば、私たちの [Slack team](https://communityinviter.com/apps/wsgrowi/invite/) にぜひご参加ください。
 いつでも、どこでも GROWI について議論しましょう!
 いつでも、どこでも GROWI について議論しましょう!
 
 
 # ライセンス
 # ライセンス

+ 1 - 1
SECURITY.md

@@ -13,7 +13,7 @@
 
 
 If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
 If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
 
 
-  * [Join our Slack team](https://growi-slackin.weseek.co.jp/) and send DM to `@yuki` who is the lead developer
+  * [Join our Slack team](https://communityinviter.com/apps/wsgrowi/invite/) and send DM to `@yuki` who is the lead developer
   * Report to JPCERT/CC[^jpcertcc]
   * Report to JPCERT/CC[^jpcertcc]
     * [[PDF] JPCERT/CC Vulnerability Coordination and Disclosure Policy](https://www.jpcert.or.jp/english/vh/vul-coordination-disclosure-policy_2019.pdf)
     * [[PDF] JPCERT/CC Vulnerability Coordination and Disclosure Policy](https://www.jpcert.or.jp/english/vh/vul-coordination-disclosure-policy_2019.pdf)
 
 

+ 1 - 1
apps/app/resource/locales/en_US/welcome.md

@@ -58,7 +58,7 @@ We can display the content list using a table and `$lsx`.
 
 
 # Slack
 # Slack
 
 
-<a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+<a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
 
 
 We welcome newcomers joining our slack channel to help improve GROWI.
 We welcome newcomers joining our slack channel to help improve GROWI.
 In addition to discussing development, we are also happy to answer your questions when you join.
 In addition to discussing development, we are also happy to answer your questions when you join.

+ 1 - 1
apps/app/resource/locales/ja_JP/welcome.md

@@ -54,7 +54,7 @@ GROWI は個人・法人向けの Wiki | ナレッジベースツールです。
 
 
 # Slack
 # Slack
 
 
-<a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+<a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
 
 
 GROWI をより良いものにするために、是非 Slack に参加してください。  
 GROWI をより良いものにするために、是非 Slack に参加してください。  
 開発に関する議論を行っている他、導入時の質問等も受け付けています。
 開発に関する議論を行っている他、導入時の質問等も受け付けています。

+ 1 - 1
apps/app/resource/locales/zh_CN/welcome.md

@@ -58,7 +58,7 @@ GROWI是一个针对个人和公司的Wiki - 一个知识库工具。
 
 
 # Slack
 # Slack
 
 
-<a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+<a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
 
 
 我们欢迎新人加入我们的slack频道,帮助改善Growi。
 我们欢迎新人加入我们的slack频道,帮助改善Growi。
 除了讨论发展问题,我们也很乐意在你加入时回答你的问题。
 除了讨论发展问题,我们也很乐意在你加入时回答你的问题。

+ 58 - 0
apps/app/src/client/services/search-operation.ts

@@ -0,0 +1,58 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
+import { useRouter } from 'next/router';
+import useSWRImmutable from 'swr/immutable';
+
+
+type UseKeywordManagerUtils = {
+  pushState: (newKeyword: string) => void,
+}
+
+export const useKeywordManager = (): SWRResponseWithUtils<UseKeywordManagerUtils, string> => {
+  // routerRef solve the problem of infinite redrawing that occurs with routers
+  const router = useRouter();
+  const routerRef = useRef(router);
+
+  // parse URL Query
+  const queries = router.query.q;
+  const initialKeyword = (Array.isArray(queries) ? queries.join(' ') : queries) ?? '';
+
+  const swrResponse = useSWRImmutable<string>('searchKeyword', null, {
+    fallbackData: initialKeyword,
+  });
+
+  const { mutate } = swrResponse;
+  const pushState = useCallback((newKeyword: string) => {
+    mutate((prevKeyword) => {
+      if (prevKeyword !== newKeyword) {
+        const newUrl = new URL('/_search', 'http://example.com');
+        newUrl.searchParams.append('q', newKeyword);
+        routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '');
+      }
+
+      return newKeyword;
+    });
+  }, [mutate]);
+
+  // detect search keyword from the query of URL
+  useEffect(() => {
+    mutate(initialKeyword);
+  }, [mutate, initialKeyword]);
+
+  // browser back and forward
+  useEffect(() => {
+    routerRef.current.beforePopState(({ url }) => {
+      const newUrl = new URL(url, 'https://exmple.com');
+      const newKeyword = newUrl.searchParams.get('q');
+      if (newKeyword != null) {
+        mutate(newKeyword);
+      }
+      return true;
+    });
+  }, [mutate]);
+
+  return withUtils(swrResponse, {
+    pushState,
+  });
+};

+ 4 - 5
apps/app/src/components/Navbar/GlobalSearch.tsx

@@ -9,6 +9,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useKeywordManager } from '~/client/services/search-operation';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import {
 import {
   useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
@@ -46,6 +47,8 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
   const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault ?? false);
   const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault ?? false);
   const [isFocused, setFocused] = useState<boolean>(false);
   const [isFocused, setFocused] = useState<boolean>(false);
 
 
+  const { pushState } = useKeywordManager();
+
   useEffect(() => {
   useEffect(() => {
     setScopeChildren(isSearchScopeChildrenAsDefault);
     setScopeChildren(isSearchScopeChildrenAsDefault);
   }, [isSearchScopeChildrenAsDefault]);
   }, [isSearchScopeChildrenAsDefault]);
@@ -63,17 +66,13 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
   }, [returnPathForURL, router]);
   }, [returnPathForURL, router]);
 
 
   const search = useCallback(() => {
   const search = useCallback(() => {
-    const url = new URL(window.location.href);
-    url.pathname = '/_search';
-
     // construct search query
     // construct search query
     let q = text;
     let q = text;
     if (isScopeChildren) {
     if (isScopeChildren) {
       q += ` prefix:${currentPagePath ?? window.location.pathname}`;
       q += ` prefix:${currentPagePath ?? window.location.pathname}`;
     }
     }
-    url.searchParams.append('q', q);
 
 
-    router.push(url.href);
+    pushState(q);
   }, [currentPagePath, isScopeChildren, router, text]);
   }, [currentPagePath, isScopeChildren, router, text]);
 
 
   const scopeLabel = isScopeChildren
   const scopeLabel = isScopeChildren

+ 10 - 1
apps/app/src/components/Page/RenderTagLabels.tsx

@@ -2,6 +2,8 @@ import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { useKeywordManager } from '~/client/services/search-operation';
+
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 
 
@@ -15,6 +17,8 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
   const { tags, isTagLabelsDisabled, openEditorModal } = props;
   const { tags, isTagLabelsDisabled, openEditorModal } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { pushState } = useKeywordManager();
+
   function openEditorHandler() {
   function openEditorHandler() {
     if (openEditorModal == null) {
     if (openEditorModal == null) {
       return;
       return;
@@ -28,7 +32,12 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
     <>
     <>
       {tags.map((tag) => {
       {tags.map((tag) => {
         return (
         return (
-          <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge bg-primary me-2">
+          <a
+            key={tag}
+            type="button"
+            className="grw-tag-label badge bg-primary me-2"
+            onClick={() => pushState(`tag:${tag}`)}
+          >
             {tag}
             {tag}
           </a>
           </a>
         );
         );

+ 15 - 40
apps/app/src/components/SearchPage.tsx

@@ -1,23 +1,21 @@
 import React, {
 import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
+  useCallback, useMemo, useRef, useState,
 } from 'react';
 } from 'react';
 
 
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
-
 
 
-import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import { IFormattedSearchResult } from '~/interfaces/search';
+import type { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { useKeywordManager } from '~/client/services/search-operation';
+import type { IFormattedSearchResult } from '~/interfaces/search';
 import { useIsSearchServiceReachable, useShowPageLimitationL } from '~/stores/context';
 import { useIsSearchServiceReachable, useShowPageLimitationL } from '~/stores/context';
-import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
+import { type ISearchConditions, type ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from './NotAvailableForReadOnlyUser';
 import { NotAvailableForReadOnlyUser } from './NotAvailableForReadOnlyUser';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 import SearchControl from './SearchPage/SearchControl';
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
+import { type IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
 
 
 // TODO: replace with "customize:showPageLimitationS"
 // TODO: replace with "customize:showPageLimitationS"
@@ -93,15 +91,8 @@ export const SearchPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: showPageLimitationL } = useShowPageLimitationL();
   const { data: showPageLimitationL } = useShowPageLimitationL();
 
 
-  // routerRef solve the problem of infinite redrawing that occurs with routers
-  const router = useRouter();
-  const routerRef = useRef(router);
+  const { data: keyword, pushState } = useKeywordManager();
 
 
-  // parse URL Query
-  const queries = router.query.q;
-  const initQ = (Array.isArray(queries) ? queries.join(' ') : queries) ?? '';
-
-  const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(showPageLimitationL ?? INITIAL_PAGIONG_SIZE);
   const [limit, setLimit] = useState<number>(showPageLimitationL ?? INITIAL_PAGIONG_SIZE);
   const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
   const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
@@ -110,17 +101,20 @@ export const SearchPage = (): JSX.Element => {
 
 
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
-  const { data, conditions, mutate } = useSWRxSearch(keyword, null, {
+  const { data, conditions, mutate } = useSWRxSearch(keyword ?? '', null, {
     ...configurationsByControl,
     ...configurationsByControl,
     offset,
     offset,
     limit,
     limit,
   });
   });
 
 
-  const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
-    setKeyword(_keyword);
+  const searchInvokedHandler = useCallback((newKeyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
     setOffset(0);
     setOffset(0);
     setConfigurationsByControl(newConfigurations);
     setConfigurationsByControl(newConfigurations);
-  }, []);
+
+    pushState(newKeyword);
+
+    mutate();
+  }, [keyword, mutate, pushState]);
 
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
     const instance = searchPageBaseRef.current;
@@ -176,25 +170,6 @@ export const SearchPage = (): JSX.Element => {
   // for bulk deletion
   // for bulk deletion
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
 
 
-  // push state
-  useEffect(() => {
-    const newUrl = new URL('/_search', 'http://example.com');
-    newUrl.searchParams.append('q', keyword);
-    routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '', { shallow: true });
-  }, [keyword, routerRef]);
-
-  // browser back and forward
-  useEffect(() => {
-    routerRef.current.beforePopState(({ url }) => {
-      const newUrl = new URL(url, 'https://exmple.com');
-      const newKeyword = newUrl.searchParams.get('q');
-      if (newKeyword != null) {
-        setKeyword(newKeyword);
-      }
-      return true;
-    });
-  }, [setKeyword, routerRef]);
-
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
   const allControl = useMemo(() => {
   const allControl = useMemo(() => {
@@ -246,7 +221,7 @@ export const SearchPage = (): JSX.Element => {
     return (
     return (
       <SearchResultListHead
       <SearchResultListHead
         searchResult={data}
         searchResult={data}
-        searchingKeyword={keyword}
+        searchingKeyword={keyword ?? ''}
         offset={offset}
         offset={offset}
         pagingSize={limit}
         pagingSize={limit}
         onPagingSizeChanged={pagingSizeChangedHandler}
         onPagingSizeChanged={pagingSizeChangedHandler}

+ 32 - 20
apps/app/src/components/SearchPage/SearchControl.tsx

@@ -18,7 +18,7 @@ type Props = {
   isEnableFilter: boolean,
   isEnableFilter: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
   initialSearchConditions: Partial<ISearchConditions>,
 
 
-  onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
+  onSearchInvoked?: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
 
 
   allControl: React.ReactNode,
   allControl: React.ReactNode,
 }
 }
@@ -34,7 +34,9 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
     allControl,
     allControl,
   } = props;
   } = props;
 
 
-  const [keyword, setKeyword] = useState(initialSearchConditions.keyword ?? '');
+  const keywordOnInit = initialSearchConditions.keyword ?? '';
+
+  const [keyword, setKeyword] = useState(keywordOnInit);
   const [sort, setSort] = useState<SORT_AXIS>(initialSearchConditions.sort ?? SORT_AXIS.RELATION_SCORE);
   const [sort, setSort] = useState<SORT_AXIS>(initialSearchConditions.sort ?? SORT_AXIS.RELATION_SCORE);
   const [order, setOrder] = useState<SORT_ORDER>(initialSearchConditions.order ?? SORT_ORDER.DESC);
   const [order, setOrder] = useState<SORT_ORDER>(initialSearchConditions.order ?? SORT_ORDER.DESC);
   const [includeUserPages, setIncludeUserPages] = useState(initialSearchConditions.includeUserPages ?? false);
   const [includeUserPages, setIncludeUserPages] = useState(initialSearchConditions.includeUserPages ?? false);
@@ -43,32 +45,42 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
 
 
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
-  const invokeSearch = useCallback(() => {
-    if (onSearchInvoked == null) {
-      return;
-    }
+  const searchFormSubmittedHandler = useCallback((input: string) => {
+    setKeyword(input);
 
 
-    onSearchInvoked(keyword, {
+    onSearchInvoked?.(input, {
       sort, order, includeUserPages, includeTrashPages,
       sort, order, includeUserPages, includeTrashPages,
     });
     });
-  }, [keyword, sort, order, includeTrashPages, includeUserPages, onSearchInvoked]);
-
-  const searchFormSubmittedHandler = useCallback((input: string) => {
-    setKeyword(input);
-  }, []);
+  }, [includeTrashPages, includeUserPages, onSearchInvoked, order, sort]);
 
 
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
     setSort(nextSort);
     setSort(nextSort);
     setOrder(nextOrder);
     setOrder(nextOrder);
-  }, []);
 
 
-  useEffect(() => {
-    invokeSearch();
-  }, [invokeSearch]);
+    onSearchInvoked?.(keyword, {
+      sort: nextSort, order: nextOrder, includeUserPages, includeTrashPages,
+    });
+  }, [includeTrashPages, includeUserPages, keyword, onSearchInvoked]);
+
+  const changeIncludeUserPagesHandler = useCallback((include: boolean) => {
+    setIncludeUserPages(include);
+
+    onSearchInvoked?.(keyword, {
+      sort, order, includeUserPages: include, includeTrashPages,
+    });
+  }, [includeTrashPages, keyword, onSearchInvoked, order, sort]);
+
+  const changeIncludeTrashPagesHandler = useCallback((include: boolean) => {
+    setIncludeTrashPages(include);
+
+    onSearchInvoked?.(keyword, {
+      sort, order, includeUserPages, includeTrashPages: include,
+    });
+  }, [includeUserPages, keyword, onSearchInvoked, order, sort]);
 
 
   useEffect(() => {
   useEffect(() => {
-    setKeyword(initialSearchConditions.keyword ?? '');
-  }, [initialSearchConditions.keyword]);
+    setKeyword(keywordOnInit);
+  }, [keywordOnInit]);
 
 
   return (
   return (
     <div className="shadow-sm">
     <div className="shadow-sm">
@@ -128,7 +140,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
                     type="checkbox"
                     type="checkbox"
                     id="flexCheckDefault"
                     id="flexCheckDefault"
                     defaultChecked={includeUserPages}
                     defaultChecked={includeUserPages}
-                    onChange={e => setIncludeUserPages(e.target.checked)}
+                    onChange={e => changeIncludeUserPagesHandler(e.target.checked)}
                   />
                   />
                   <label
                   <label
                     className="form-label form-check-label mb-0 d-flex align-items-center text-secondary with-no-font-weight"
                     className="form-label form-check-label mb-0 d-flex align-items-center text-secondary with-no-font-weight"
@@ -145,7 +157,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
                     type="checkbox"
                     type="checkbox"
                     id="flexCheckChecked"
                     id="flexCheckChecked"
                     checked={includeTrashPages}
                     checked={includeTrashPages}
-                    onChange={e => setIncludeTrashPages(e.target.checked)}
+                    onChange={e => changeIncludeTrashPagesHandler(e.target.checked)}
                   />
                   />
                   <label
                   <label
                     className="form-label form-check-label d-flex align-items-center text-secondary with-no-font-weight"
                     className="form-label form-check-label d-flex align-items-center text-secondary with-no-font-weight"

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

@@ -5,8 +5,8 @@ import React, {
 import { isPopulated, type IPageHasId } from '@growi/core';
 import { isPopulated, type IPageHasId } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture, FootstampIcon } from '@growi/ui/dist/components';
 import { UserPicture, FootstampIcon } from '@growi/ui/dist/components';
-import Link from 'next/link';
 
 
+import { useKeywordManager } from '~/client/services/search-operation';
 import FormattedDistanceDate from '~/components/FormattedDistanceDate';
 import FormattedDistanceDate from '~/components/FormattedDistanceDate';
 import InfiniteScroll from '~/components/InfiniteScroll';
 import InfiniteScroll from '~/components/InfiniteScroll';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
@@ -27,7 +27,8 @@ type PageItemLowerProps = {
 }
 }
 
 
 type PageItemProps = PageItemLowerProps & {
 type PageItemProps = PageItemLowerProps & {
-  isSmall: boolean
+  isSmall: boolean,
+  onClickTag?: (tagName: string) => void,
 }
 }
 
 
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
@@ -47,7 +48,7 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
 });
 });
 PageItemLower.displayName = 'PageItemLower';
 PageItemLower.displayName = 'PageItemLower';
 
 
-const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
+const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -68,14 +69,14 @@ const PageItem = memo(({ page, isSmall }: PageItemProps): JSX.Element => {
       return <></>;
       return <></>;
     }
     }
     return (
     return (
-      <Link
+      <a
         key={tag.name}
         key={tag.name}
-        href={`/_search?q=tag:${tag.name}`}
+        type="button"
         className="grw-tag-label badge bg-primary me-2 small"
         className="grw-tag-label badge bg-primary me-2 small"
-        prefetch={false}
+        onClick={() => onClickTag?.(tag.name)}
       >
       >
         {tag.name}
         {tag.name}
-      </Link>
+      </a>
     );
     );
   });
   });
 
 
@@ -156,6 +157,8 @@ export const RecentChangesContent = ({ isSmall }: ContentProps): JSX.Element =>
   const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
   const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
   const { data } = swrInifinitexRecentlyUpdated;
   const { data } = swrInifinitexRecentlyUpdated;
 
 
+  const { pushState } = useKeywordManager();
+
   const isEmpty = data?.[0]?.pages.length === 0;
   const isEmpty = data?.[0]?.pages.length === 0;
   const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
   const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
 
@@ -168,7 +171,7 @@ export const RecentChangesContent = ({ isSmall }: ContentProps): JSX.Element =>
         >
         >
           { data != null && data.map(apiResult => apiResult.pages).flat()
           { data != null && data.map(apiResult => apiResult.pages).flat()
             .map(page => (
             .map(page => (
-              <PageItem key={page._id} page={page} isSmall={isSmall} />
+              <PageItem key={page._id} page={page} isSmall={isSmall} onClickTag={tagName => pushState(`tag:${tagName}`)} />
             ))
             ))
           }
           }
         </InfiniteScroll>
         </InfiniteScroll>

+ 7 - 9
apps/app/src/components/TagCloudBox.tsx

@@ -1,7 +1,6 @@
 import React, { FC, memo } from 'react';
 import React, { FC, memo } from 'react';
 
 
-import Link from 'next/link';
-
+import { useKeywordManager } from '~/client/services/search-operation';
 import { IDataTagCount } from '~/interfaces/tag';
 import { IDataTagCount } from '~/interfaces/tag';
 
 
 
 
@@ -23,21 +22,20 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
   const { tags } = props;
   const { tags } = props;
   const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
   const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
 
+  const { pushState } = useKeywordManager();
+
   const tagElements = tags.map((tag:IDataTagCount) => {
   const tagElements = tags.map((tag:IDataTagCount) => {
     const tagNameFormat = (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name;
     const tagNameFormat = (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name;
 
 
-    const url = new URL('/_search', 'https://example.com');
-    url.searchParams.append('q', `tag:${tag.name}`);
-
     return (
     return (
-      <Link
+      <a
         key={tag.name}
         key={tag.name}
-        href={`${url.pathname}${url.search}`}
+        type="button"
         className="grw-tag-label badge bg-primary me-2"
         className="grw-tag-label badge bg-primary me-2"
-        prefetch={false}
+        onClick={() => pushState(`tag:${tag.name}`)}
       >
       >
         {tagNameFormat}
         {tagNameFormat}
-      </Link>
+      </a>
     );
     );
   });
   });
 
 

+ 12 - 15
apps/app/src/components/TagList.tsx

@@ -3,8 +3,8 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 
+import { useKeywordManager } from '~/client/services/search-operation';
 import { IDataTagCount } from '~/interfaces/tag';
 import { IDataTagCount } from '~/interfaces/tag';
 
 
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
@@ -29,26 +29,23 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   const isTagExist: boolean = tagData.length > 0;
   const isTagExist: boolean = tagData.length > 0;
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
-  const generateTagList = useCallback((tagData) => {
-    return tagData.map((tag:IDataTagCount, index:number) => {
-      const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
-
-      const url = new URL('/_search', 'https://example.com');
-      url.searchParams.append('q', `tag:${tag.name}`);
+  const { pushState } = useKeywordManager();
 
 
+  const generateTagList = useCallback((tagData) => {
+    return tagData.map((tag:IDataTagCount) => {
       return (
       return (
-        <Link
+        <button
           key={tag._id}
           key={tag._id}
-          href={`${url.pathname}${url.search}`}
-          className={tagListClasses}
-          prefetch={false}
+          type="button"
+          className="list-group-item list-group-item-action d-flex"
+          onClick={() => pushState(`tag:${tag.name}`)}
         >
         >
           <div className="text-truncate list-tag-name">{tag.name}</div>
           <div className="text-truncate list-tag-name">{tag.name}</div>
           <div className="ms-4 my-auto py-1 px-2 list-tag-count badge bg-primary">{tag.count}</div>
           <div className="ms-4 my-auto py-1 px-2 list-tag-count badge bg-primary">{tag.count}</div>
-        </Link>
+        </button>
       );
       );
     });
     });
-  }, []);
+  }, [pushState]);
 
 
   if (!isTagExist) {
   if (!isTagExist) {
     return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
     return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
@@ -56,9 +53,9 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
 
 
   return (
   return (
     <>
     <>
-      <ul className="list-group text-start mb-5">
+      <div className="list-group text-start mb-5">
         {generateTagList(tagData)}
         {generateTagList(tagData)}
-      </ul>
+      </div>
       {isPaginationShown
       {isPaginationShown
       && (
       && (
         <PaginationWrapper
         <PaginationWrapper

+ 13 - 4
apps/app/src/stores/tag.tsx

@@ -1,19 +1,28 @@
-import { SWRResponse } from 'swr';
-import useSWRImmutable from 'swr/immutable';
+import useSWR, { SWRResponse } from 'swr';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { IResTagsListApiv1, IResTagsSearchApiv1 } from '~/interfaces/tag';
 import { IResTagsListApiv1, IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
-  return useSWRImmutable(
+  return useSWR(
     ['/tags.list', limit, offset],
     ['/tags.list', limit, offset],
     ([endpoint, limit, offset]) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
     ([endpoint, limit, offset]) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
+    {
+      keepPreviousData: true,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
   );
   );
 };
 };
 
 
 export const useSWRxTagsSearch = (query: string): SWRResponse<IResTagsSearchApiv1, Error> => {
 export const useSWRxTagsSearch = (query: string): SWRResponse<IResTagsSearchApiv1, Error> => {
-  return useSWRImmutable(
+  return useSWR(
     ['/tags.search', query],
     ['/tags.search', query],
     ([endpoint, query]) => apiGet(endpoint, { q: query }).then((result: IResTagsSearchApiv1) => result),
     ([endpoint, query]) => apiGet(endpoint, { q: query }).then((result: IResTagsSearchApiv1) => result),
+    {
+      keepPreviousData: true,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
   );
   );
 };
 };

+ 2 - 1
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts

@@ -65,7 +65,8 @@ context('Access to any page', () => {
     });
     });
     cy.waitUntil(() => {
     cy.waitUntil(() => {
       cy.getByTestid('grw-subnav-switcher').within(() => {
       cy.getByTestid('grw-subnav-switcher').within(() => {
-        cy.getByTestid('editor-button').should('be.visible').click();
+        cy.getByTestid('editor-button').as('editorButton').should('be.visible');
+        cy.get('@editorButton').click();
       });
       });
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
     });
     });

+ 1 - 1
apps/slackbot-proxy/src/views/top.ejs

@@ -38,7 +38,7 @@
           </ul>
           </ul>
         <div class="mt-3">
         <div class="mt-3">
           GROWI is open-source software developed by WESEEK, Inc and we are looking for contributors who can work with us.<br>
           GROWI is open-source software developed by WESEEK, Inc and we are looking for contributors who can work with us.<br>
-          Please <a href="https://growi-slackin.weseek.co.jp/">join</a> Slack and feel free to talk to WESEEK members!
+          Please <a href="https://communityinviter.com/apps/wsgrowi/invite/">join</a> Slack and feel free to talk to WESEEK members!
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>