Explorar el Código

Merge pull request #10229 from weseek/feat/169634-implementation-of-editable-page-path-list

feat: Page list with editable page paths
Yuki Takei hace 8 meses
padre
commit
117043ec58

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

@@ -2,6 +2,7 @@ import React, { useCallback, type JSX } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
 
 import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 
@@ -42,12 +43,15 @@ export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
             <PageSelectionMethodButtons />
           </div>
 
-          <SelectablePagePageList
-            method="delete"
-            methodButtonPosition="right"
-            pages={selectedPages}
-            onClickMethodButton={removePageHandler}
-          />
+          <SimpleBar style={{ maxHeight: '300px' }}>
+            <SelectablePagePageList
+              isEditable
+              method="delete"
+              methodButtonPosition="right"
+              pages={selectedPages}
+              onClickMethodButton={removePageHandler}
+            />
+          </SimpleBar>
         </div>
       </ModalBody>
     </>

+ 12 - 7
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -6,6 +6,7 @@ import type { IPageHasId } from '@growi/core';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import { type TypeaheadRef, Typeahead } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
+import SimpleBar from 'simplebar-react';
 import {
   ModalBody,
 } from 'reactstrap';
@@ -45,7 +46,7 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
 
   const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
   const {
-    selectedPages, addPage, removePage,
+    selectedPages, selectedPagesArray, addPage, removePage,
   } = useSelectedPages(baseSelectedPages);
 
   const joinedSelectedSearchKeywords = useMemo(() => {
@@ -173,10 +174,11 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
             </h4>
             <div className="px-4">
               <SelectablePagePageList
+                isEditable
                 pages={pagesWithGlobPath ?? []}
                 method="add"
                 onClickMethodButton={addPage}
-                disablePagePaths={Array.from(selectedPages.values()).map(page => page.path)}
+                disablePagePaths={selectedPagesArray.map(page => page.path)}
               />
             </div>
           </>
@@ -187,14 +189,17 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
         </h4>
 
         <div className="px-4">
-          <SelectablePagePageList
-            pages={Array.from(selectedPages.values())}
-            method="remove"
-            onClickMethodButton={removePage}
-          />
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePagePageList
+              pages={selectedPagesArray}
+              method="remove"
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
           <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">

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

@@ -317,7 +317,7 @@ export const AiAssistantManagementModal = (): JSX.Element => {
   const isOpened = aiAssistantManagementModalData?.isOpened ?? false;
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass} scrollable>
+    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass}>
       { isOpened && (
         <AiAssistantManagementModalSubstance />
       ) }

+ 26 - 22
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -1,11 +1,12 @@
 import React, {
-  Suspense, useCallback, memo, useMemo,
+  Suspense, useCallback, memo,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
   ModalBody,
 } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
 
 import { ItemsTree } from '~/client/components/ItemsTree';
 import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
@@ -62,7 +63,7 @@ const SelectablePageTree = memo((props: { onClickAddPageButton: (page: Selectabl
       <TreeItemLayout
         {...props}
         itemClass={PageTreeItem}
-        className=" text-muted"
+        className="text-muted"
         customHoveredEndComponents={[SelectPageButton]}
       />
     );
@@ -93,23 +94,24 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
   const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
 
   const {
-    selectedPages, addPage, removePage,
+    selectedPages, selectedPagesRef, selectedPagesArray, addPage, removePage,
   } = useSelectedPages(baseSelectedPages);
 
-  // SelectedPages will include subordinate pages by default
-  const pagesWithGlobPath = useMemo(() => {
-    return Array.from(selectedPages.values()).map((page) => {
-      if (page.path === '/') {
-        page.path = '/*';
-      }
 
-      if (!page.path.endsWith('/*')) {
-        page.path = `${page.path}/*`;
-      }
+  const addPageButtonClickHandler = useCallback((page: SelectablePage) => {
+    const pagePathWithGlob = `${page.path}/*`;
+    if (selectedPagesRef.current == null || selectedPagesRef.current.has(pagePathWithGlob)) {
+      return;
+    }
+
+    const clonedPage = { ...page };
+    clonedPage.path = pagePathWithGlob;
 
-      return page;
-    });
-  }, [selectedPages]);
+    addPage(clonedPage);
+  }, [
+    addPage,
+    selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
+  ]);
 
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
@@ -131,7 +133,7 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
 
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
           <div className="px-4">
-            <SelectablePageTree onClickAddPageButton={addPage} />
+            <SelectablePageTree onClickAddPageButton={addPageButtonClickHandler} />
           </div>
         </Suspense>
 
@@ -140,12 +142,14 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
         </h4>
 
         <div className="px-4">
-          <SelectablePagePageList
-            method="remove"
-            methodButtonPosition="right"
-            pages={pagesWithGlobPath ?? []}
-            onClickMethodButton={removePage}
-          />
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePagePageList
+              method="remove"
+              methodButtonPosition="right"
+              pages={selectedPagesArray}
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
           <label className="form-text text-muted mt-2">
             {t('modal_ai_assistant.can_add_later')}
           </label>

+ 23 - 12
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePagePageList.module.scss

@@ -1,6 +1,29 @@
 @use '~/styles/variables' as var;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
+ .selectable-page-page-list :global {
+    .page-path {
+      display: inline-block;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      vertical-align: middle;
+      border: 2px solid transparent;
+    }
+
+    .page-path-editable {
+      cursor: pointer;
+      &:hover {
+        border-color: var(--bs-primary-border-subtle);
+      }
+    }
+
+    .page-path-input {
+      border: 2px solid var(--bs-border-color);
+    }
+}
+
 
 // == Colors
 @include bs.color-mode(light) {
@@ -8,12 +31,6 @@
     .page-list-item {
       background-color: #{bs.$gray-100};
     }
-
-    .list-group-item {
-      &:hover {
-        background-color: var(--grw-primary-100);
-      }
-    }
   }
 }
 
@@ -22,11 +39,5 @@
     .page-list-item {
       background-color: #{bs.$gray-900};
     }
-
-    .list-group-item {
-      &:hover {
-        background-color: var(--grw-primary-800)
-      }
-    }
   }
 }

+ 144 - 50
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePagePageList.tsx

@@ -1,6 +1,12 @@
-import React, { useMemo, memo } from 'react';
+import React, {
+  useMemo, memo, useState, useCallback, useRef, useEffect,
+} from 'react';
 
+import { pathUtils } from '@growi/core/dist/utils';
+import { isCreatablePagePathPattern } from '../../../../utils/is-creatable-page-path-pattern';
+import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'react-i18next';
+import AutosizeInput from 'react-input-autosize';
 
 import { type SelectablePage } from '../../../../interfaces/selectable-page';
 
@@ -11,8 +17,7 @@ const moduleClass = styles['selectable-page-page-list'] ?? '';
 type MethodButtonProps = {
   page: SelectablePage;
   disablePagePaths: string[];
-  methodButtonColor: string;
-  methodButtonIconName: string;
+  method: 'add' | 'remove' | 'delete'
   onClickMethodButton: (page: SelectablePage) => void;
 }
 
@@ -20,15 +25,40 @@ const MethodButton = memo((props: MethodButtonProps) => {
   const {
     page,
     disablePagePaths,
-    methodButtonColor,
-    methodButtonIconName,
+    method,
     onClickMethodButton,
   } = props;
 
+  const iconName = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'add_circle';
+      case 'remove':
+        return 'do_not_disturb_on';
+      case 'delete':
+        return 'delete';
+      default:
+        return '';
+    }
+  }, [method]);
+
+  const color = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'text-primary';
+      case 'remove':
+        return 'text-secondary';
+      case 'delete':
+        return 'text-secondary';
+      default:
+        return '';
+    }
+  }, [method]);
+
   return (
     <button
       type="button"
-      className={`btn border-0 ${methodButtonColor}`}
+      className={`btn border-0 ${color}`}
       disabled={disablePagePaths.includes(page.path)}
       onClick={(e) => {
         e.stopPropagation();
@@ -36,18 +66,113 @@ const MethodButton = memo((props: MethodButtonProps) => {
       }}
     >
       <span className="material-symbols-outlined">
-        {methodButtonIconName}
+        {iconName}
       </span>
     </button>
   );
 });
 
 
+type EditablePagePathProps = {
+  isEditable?: boolean;
+  page: SelectablePage;
+  disablePagePaths: string[];
+  methodButtonPosition?: 'left' | 'right';
+}
+
+const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
+  const {
+    page,
+    isEditable,
+    disablePagePaths = [],
+    methodButtonPosition = 'left',
+  } = props;
+
+  const [editingPagePath, setEditingPagePath] = useState<string | null>(null);
+  const [inputValue, setInputValue] = useState('');
+
+  const inputRef = useRef<HTMLInputElement & AutosizeInput | null>(null);
+  const editingContainerRef = useRef<HTMLDivElement>(null);
+  const [editingContainerRect] = useRect(editingContainerRef);
+
+  const isEditing = isEditable && editingPagePath === page.path;
+
+  const handlePagePathClick = useCallback((page: SelectablePage) => {
+    if (!isEditable || disablePagePaths.includes(page.path)) {
+      return;
+    }
+    setEditingPagePath(page.path);
+    setInputValue(page.path);
+  }, [disablePagePaths, isEditable]);
+
+  const handleInputBlur = useCallback(() => {
+    setEditingPagePath(null);
+  }, []);
+
+  const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter') {
+
+      // Validate page path
+      const pagePathWithSlash = pathUtils.addHeadingSlash(inputValue);
+      if (inputValue === '' || disablePagePaths.includes(pagePathWithSlash) || !isCreatablePagePathPattern(pagePathWithSlash)) {
+        handleInputBlur();
+        return;
+      }
+
+      // Update page path
+      page.path = pagePathWithSlash;
+
+      handleInputBlur();
+    }
+  }, [disablePagePaths, handleInputBlur, inputValue, page]);
+
+  // Autofocus
+  useEffect(() => {
+    if (editingPagePath != null && inputRef.current != null) {
+      inputRef.current.focus();
+    }
+  }, [editingPagePath]);
+
+  return (
+    <div
+      ref={editingContainerRef}
+      className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-2' : 'mx-2'}`}
+      style={{ minWidth: 0 }}
+    >
+      {isEditing
+        ? (
+          <AutosizeInput
+            id="page-path-input"
+            inputClassName="page-path-input"
+            type="text"
+            ref={inputRef}
+            value={inputValue}
+            onBlur={handleInputBlur}
+            onChange={e => setInputValue(e.target.value)}
+            onKeyDown={handleInputKeyDown}
+            inputStyle={{ maxWidth: (editingContainerRect?.width ?? 0) - 10 }}
+          />
+        )
+        : (
+          <span
+            className={`page-path ${isEditable && !disablePagePaths.includes(page.path) ? 'page-path-editable' : ''}`}
+            onClick={() => handlePagePathClick(page)}
+            title={page.path}
+          >
+            {page.path}
+          </span>
+        )}
+    </div>
+  );
+});
+
+
 type SelectablePagePageListProps = {
   pages: SelectablePage[],
   method: 'add' | 'remove' | 'delete'
   methodButtonPosition?: 'left' | 'right',
   disablePagePaths?: string[],
+  isEditable?: boolean,
   onClickMethodButton: (page: SelectablePage) => void,
 }
 
@@ -57,38 +182,12 @@ export const SelectablePagePageList = (props: SelectablePagePageListProps): JSX.
     method,
     methodButtonPosition = 'left',
     disablePagePaths = [],
+    isEditable,
     onClickMethodButton,
   } = props;
 
   const { t } = useTranslation();
 
-  const methodButtonIconName = useMemo(() => {
-    switch (method) {
-      case 'add':
-        return 'add_circle';
-      case 'remove':
-        return 'do_not_disturb_on';
-      case 'delete':
-        return 'delete';
-      default:
-        return '';
-    }
-  }, [method]);
-
-  const methodButtonColor = useMemo(() => {
-    switch (method) {
-      case 'add':
-        return 'text-primary';
-      case 'remove':
-        return 'text-secondary';
-      case 'delete':
-        return 'text-secondary';
-      default:
-        return '';
-    }
-  }, [method]);
-
-
   if (pages.length === 0) {
     return (
       <div className={moduleClass}>
@@ -103,32 +202,28 @@ export const SelectablePagePageList = (props: SelectablePagePageListProps): JSX.
     <div className={`list-group ${moduleClass}`}>
       {pages.map((page) => {
         return (
-          <button
+          <div
             key={page.path}
-            type="button"
-            className="list-group-item border-0 list-group-item-action page-list-item d-flex align-items-center p-1 mb-2 rounded"
-            onClick={(e) => {
-              e.stopPropagation();
-            }}
+            className="list-group-item border-0 page-list-item d-flex align-items-center p-1 mb-2 rounded"
           >
 
             {methodButtonPosition === 'left'
               && (
                 <MethodButton
                   page={page}
+                  method={method}
                   disablePagePaths={disablePagePaths}
-                  methodButtonColor={methodButtonColor}
-                  methodButtonIconName={methodButtonIconName}
                   onClickMethodButton={onClickMethodButton}
                 />
               )
             }
 
-            <div className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-4' : 'ms-2'}`}>
-              <span>
-                {page.path}
-              </span>
-            </div>
+            <EditablePagePath
+              page={page}
+              isEditable={isEditable}
+              disablePagePaths={disablePagePaths}
+              methodButtonPosition={methodButtonPosition}
+            />
 
             <span className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}>
               <span className="text-body-tertiary">
@@ -140,14 +235,13 @@ export const SelectablePagePageList = (props: SelectablePagePageListProps): JSX.
               && (
                 <MethodButton
                   page={page}
+                  method={method}
                   disablePagePaths={disablePagePaths}
-                  methodButtonColor={methodButtonColor}
-                  methodButtonIconName={methodButtonIconName}
                   onClickMethodButton={onClickMethodButton}
                 />
               )
             }
-          </button>
+          </div>
         );
       })}
     </div>

+ 18 - 1
apps/app/src/features/openai/client/services/use-selected-pages.tsx

@@ -1,4 +1,6 @@
-import { useState, useCallback, useEffect } from 'react';
+import {
+  useState, useCallback, useEffect, useMemo, useRef,
+} from 'react';
 
 import type { SelectablePage } from '../../interfaces/selectable-page';
 import { useAiAssistantManagementModal } from '../stores/ai-assistant';
@@ -6,6 +8,8 @@ import { useAiAssistantManagementModal } from '../stores/ai-assistant';
 
 type UseSelectedPages = {
   selectedPages: Map<string, SelectablePage>,
+  selectedPagesRef: React.RefObject<Map<string, SelectablePage>>,
+  selectedPagesArray: SelectablePage[],
   addPage: (page: SelectablePage) => void,
   removePage: (page: SelectablePage) => void,
 }
@@ -14,6 +18,16 @@ export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPa
   const [selectedPages, setSelectedPages] = useState<Map<string, SelectablePage>>(new Map());
   const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
 
+  const selectedPagesRef = useRef(selectedPages);
+
+  const selectedPagesArray = useMemo(() => {
+    return Array.from(selectedPages.values());
+  }, [selectedPages]);
+
+  useEffect(() => {
+    selectedPagesRef.current = selectedPages;
+  }, [selectedPages]);
+
   useEffect(() => {
     // Initialize each time PageMode is changed
     if (initialPages != null && aiAssistantManagementModalData?.pageMode != null) {
@@ -47,8 +61,11 @@ export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPa
     });
   }, []);
 
+
   return {
     selectedPages,
+    selectedPagesRef,
+    selectedPagesArray,
     addPage,
     removePage,
   };

+ 2 - 7
apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

@@ -1,6 +1,6 @@
 import { GroupType } from '@growi/core';
-import { isGlobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { type ValidationChain, body } from 'express-validator';
+import { isCreatablePagePathPattern } from '../../../utils/is-creatable-page-path-pattern';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
 
@@ -42,12 +42,7 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
     .notEmpty()
     .withMessage('pagePathPatterns must not be empty')
     .custom((value: string) => {
-      // check if the value is a glob pattern path
-      if (value.includes('*')) {
-        return isGlobPatternPath(value) && isCreatablePage(value.replaceAll('*', ''));
-      }
-
-      return isCreatablePage(value);
+      return isCreatablePagePathPattern(value);
     }),
 
   body('grantedGroupsForShareScope')

+ 13 - 0
apps/app/src/features/openai/utils/is-creatable-page-path-pattern.ts

@@ -0,0 +1,13 @@
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { removeGlobPath } from './remove-glob-path';
+
+export const isCreatablePagePathPattern = (pagePath: string): boolean => {
+  const isGlobPattern = pagePathUtils.isGlobPatternPath(pagePath);
+  if (isGlobPattern) {
+    // Remove glob pattern since glob paths are non-creatable in GROWI
+    const pathWithoutGlob = removeGlobPath([pagePath])[0];
+    return pagePathUtils.isCreatablePage(pathWithoutGlob);
+  }
+
+  return pagePathUtils.isCreatablePage(pagePath);
+};