Przeglądaj źródła

Merge pull request #10209 from weseek/feat/169763-implementation-of-modal-to-select-pages-from-page-tree

feat: Implementation of modal to select pages from page tree
Yuki Takei 8 miesięcy temu
rodzic
commit
5cda3111a5

+ 2 - 17
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -12,6 +12,7 @@ import {
 
 import { useSWRxSearch } from '~/stores/search';
 
+import { useSelectedPages } from '../../../services/use-selected-pages';
 import {
   useAiAssistantManagementModal, AiAssistantManagementModalPageMode,
 } from '../../../stores/ai-assistant';
@@ -37,7 +38,7 @@ export const AiAssistantKeywordSearch = (props: { updateBaseSelectedPages: (page
   const { updateBaseSelectedPages } = props;
 
   const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
-  const [selectedPages, setSelectedPages] = useState<Map<string, IPageHasId>>(new Map());
+  const { selectedPages, addPageHandler, removePageHandler } = useSelectedPages();
 
   const joinedSelectedSearchKeywords = useMemo(() => {
     return selectedSearchKeywords.map(item => item.label).join(' ');
@@ -122,22 +123,6 @@ export const AiAssistantKeywordSearch = (props: { updateBaseSelectedPages: (page
     handleMenuItemSelect(initialItem, event);
   }, [selectedSearchKeywords]);
 
-  const addPageHandler = useCallback((page: IPageHasId) => {
-    setSelectedPages((prev) => {
-      const newMap = new Map(prev);
-      newMap.set(page._id, page);
-      return newMap;
-    });
-  }, []);
-
-  const removePageHandler = useCallback((page: IPageHasId) => {
-    setSelectedPages((prev) => {
-      const newMap = new Map(prev);
-      newMap.delete(page._id);
-      return newMap;
-    });
-  }, []);
-
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
     changePageMode(AiAssistantManagementModalPageMode.HOME);

+ 10 - 3
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -34,6 +34,7 @@ import { AiAssistantManagementEditShare } from './AiAssistantManagementEditShare
 import { AiAssistantManagementHome } from './AiAssistantManagementHome';
 import { AiAssistantKeywordSearch } from './AiAssistantManagementKeywordSearch';
 import { AiAssistantManagementPageSelectionMethod } from './AiAssistantManagementPageSelectionMethod';
+import { AiAssistantManagementPageTreeSelection } from './AiAssistantManagementPageTreeSelection';
 
 import styles from './AiAssistantManagementModal.module.scss';
 
@@ -118,9 +119,9 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
 
 
   /*
-  *  For AiAssistantManagementKeywordSearch methods
+  *  For AiAssistantManagementKeywordSearch & AiAssistantManagementPageTreeSelection methods
   */
-  const selectPageHandlerByKeywordSearch = useCallback((pages: IPageHasId[]) => {
+  const selectPageHandlerForKeywordSearchOrPageTreeSelection = useCallback((pages: IPageHasId[]) => {
     if (pages.length === 0) {
       return;
     }
@@ -272,7 +273,13 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
 
         <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
           <AiAssistantKeywordSearch
-            updateBaseSelectedPages={selectPageHandlerByKeywordSearch}
+            updateBaseSelectedPages={selectPageHandlerForKeywordSearchOrPageTreeSelection}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION}>
+          <AiAssistantManagementPageTreeSelection
+            updateBaseSelectedPages={selectPageHandlerForKeywordSearchOrPageTreeSelection}
           />
         </TabPane>
 

+ 11 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -0,0 +1,11 @@
+.grw-ai-assistant-management-page-tree-selection :global {
+  .next-button {
+    width: 30%;
+  }
+
+  .page-tree-item {
+    .list-group-item {
+      padding: 0.4rem 0 !important;
+    }
+  }
+}

+ 172 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -0,0 +1,172 @@
+import React, {
+  Suspense, useCallback, memo, useMemo,
+} from 'react';
+
+import type { IPageHasId } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+
+import { ItemsTree } from '~/client/components/ItemsTree';
+import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import type { TreeItemProps } from '~/client/components/TreeItem';
+import { TreeItemLayout } from '~/client/components/TreeItem';
+import type { IPageForItem } from '~/interfaces/page';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+
+import { useSelectedPages } from '../../../services/use-selected-pages';
+import { AiAssistantManagementModalPageMode, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePagePageList } from './SelectablePagePageList';
+
+import styles from './AiAssistantManagementPageTreeSelection.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
+
+export const isIPageHasId = (value?: IPageForItem): value is IPageHasId => {
+  if (value == null) {
+    return false;
+  }
+  return value._id != null && value.path != null;
+};
+
+
+const SelectablePageTree = memo((props: { onClickAddPageButton: (page: IPageHasId) => void }) => {
+  const { onClickAddPageButton } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const pageTreeItemClickHandler = useCallback((page: IPageForItem) => {
+    if (!isIPageHasId(page)) {
+      return;
+    }
+
+    onClickAddPageButton(page);
+  }, [onClickAddPageButton]);
+
+  const PageTreeItem = (props: TreeItemProps) => {
+    const { itemNode } = props;
+    const { page } = itemNode;
+
+    const SelectPageButton = () => {
+      return (
+        <button
+          type="button"
+          className="border-0 rounded btn p-0 me-2"
+          onClick={(e) => {
+            e.stopPropagation();
+            pageTreeItemClickHandler(page);
+          }}
+        >
+          <span className="material-symbols-outlined p-0 me-2 text-primary">add_circle</span>
+        </button>
+      );
+    };
+
+    return (
+      <TreeItemLayout
+        {...props}
+        itemClass={PageTreeItem}
+        className=" text-muted"
+        customHoveredEndComponents={[SelectPageButton]}
+      />
+    );
+  };
+
+  return (
+    <div className="page-tree-item">
+      <ItemsTree
+        targetPath="/"
+        isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
+        CustomTreeItem={PageTreeItem}
+      />
+    </div>
+  );
+});
+
+
+export const AiAssistantManagementPageTreeSelection = (props: { updateBaseSelectedPages: (pages: IPageHasId[]) => void}): JSX.Element => {
+  const { updateBaseSelectedPages } = props;
+
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const { selectedPages, addPageHandler, removePageHandler } = useSelectedPages();
+
+  // SelectedPages will include subordinate pages by default
+  const pagesWithGlobPath = useMemo((): IPageHasId[] | undefined => {
+    if (selectedPages.size === 0) {
+      return;
+    }
+    return Array.from(selectedPages.values()).map((page) => {
+      if (page.path === '/') {
+        page.path = '/*';
+      }
+
+      if (!page.path.endsWith('/*')) {
+        page.path = `${page.path}/*`;
+      }
+
+      return page;
+    });
+  }, [selectedPages]);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(AiAssistantManagementModalPageMode.HOME);
+  }, [changePageMode, selectedPages, updateBaseSelectedPages]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-3 mt-2">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <div className="px-4">
+            <SelectablePageTree onClickAddPageButton={addPageHandler} />
+          </div>
+        </Suspense>
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SelectablePagePageList
+            method="remove"
+            methodButtonPosition="right"
+            pages={pagesWithGlobPath ?? []}
+            onClickMethodButton={removePageHandler}
+          />
+          <label className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.can_add_later')}
+          </label>
+        </div>
+
+        <div className="d-flex justify-content-center mt-4">
+          <button
+            type="button"
+            className="btn btn-primary rounded next-button"
+            disabled={selectedPages.size === 0}
+            onClick={nextButtonClickHandler}
+          >
+            {t('modal_ai_assistant.next')}
+          </button>
+        </div>
+      </ModalBody>
+    </div>
+  );
+};

+ 32 - 16
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePagePageList.tsx

@@ -8,17 +8,40 @@ const moduleClass = styles['selectable-page-page-list'] ?? '';
 type Props = {
   pages: IPageHasId[],
   method: 'add' | 'remove',
+  methodButtonPosition?: 'left' | 'right',
   disablePageIds?: string[],
   onClickMethodButton: (page: IPageHasId) => void,
 }
 
 export const SelectablePagePageList = (props: Props): JSX.Element => {
   const {
-    pages, method, disablePageIds, onClickMethodButton,
+    pages,
+    method,
+    methodButtonPosition = 'left',
+    disablePageIds,
+    onClickMethodButton,
   } = props;
 
   const { t } = useTranslation();
 
+  const methodButton = (page: IPageHasId) => {
+    return (
+      <button
+        type="button"
+        className={`btn border-0 ${method === 'add' ? 'text-primary' : 'text-secondary'}`}
+        disabled={disablePageIds?.includes(page._id)}
+        onClick={(e) => {
+          e.stopPropagation();
+          onClickMethodButton(page);
+        }}
+      >
+        <span className="material-symbols-outlined">
+          { method === 'add' ? 'add_circle' : 'do_not_disturb_on' }
+        </span>
+      </button>
+    );
+  };
+
   if (pages.length === 0) {
     return (
       <div className={moduleClass}>
@@ -41,29 +64,22 @@ export const SelectablePagePageList = (props: Props): JSX.Element => {
               e.stopPropagation();
             }}
           >
-            <button
-              type="button"
-              className={`btn border-0 ${method === 'add' ? 'text-primary' : 'text-secondary'}`}
-              disabled={disablePageIds?.includes(page._id)}
-              onClick={(e) => {
-                e.stopPropagation();
-                onClickMethodButton(page);
-              }}
-            >
-              <span className="material-symbols-outlined">
-                { method === 'add' ? 'add_circle' : 'do_not_disturb_on' }
-              </span>
-            </button>
-            <div className="flex-grow-1">
+
+            {methodButtonPosition === 'left' && methodButton(page)}
+
+            <div className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-4' : 'ms-2'}`}>
               <span>
                 {page.path}
               </span>
             </div>
-            <span className="badge bg-body-secondary rounded-pill me-2">
+
+            <span className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}>
               <span className="text-body-tertiary">
                 {page.descendantCount}
               </span>
             </span>
+
+            {methodButtonPosition === 'right' && methodButton(page)}
           </button>
         );
       })}

+ 35 - 0
apps/app/src/features/openai/client/services/use-selected-pages.ts

@@ -0,0 +1,35 @@
+import { useState, useCallback } from 'react';
+
+import type { IPageHasId } from '@growi/core';
+
+type UseSelectedPages = {
+  selectedPages: Map<string, IPageHasId>,
+  addPageHandler: (page: IPageHasId) => void,
+  removePageHandler: (page: IPageHasId) => void,
+}
+
+export const useSelectedPages = (): UseSelectedPages => {
+  const [selectedPages, setSelectedPages] = useState<Map<string, IPageHasId>>(new Map());
+
+  const addPageHandler = useCallback((page: IPageHasId) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      newMap.set(page._id, page);
+      return newMap;
+    });
+  }, []);
+
+  const removePageHandler = useCallback((page: IPageHasId) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      newMap.delete(page._id);
+      return newMap;
+    });
+  }, []);
+
+  return {
+    selectedPages,
+    addPageHandler,
+    removePageHandler,
+  };
+};