diff --git a/frontend/src/components/shared/README.md b/frontend/src/components/shared/README.md index 234a9036..233530eb 100644 --- a/frontend/src/components/shared/README.md +++ b/frontend/src/components/shared/README.md @@ -42,7 +42,7 @@ that every list reuses. | File | Role | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | [`detail-navigation/`](detail-navigation/) | Prev / Position / Next toolbar + listbox sheet for detail pages, and the navigation hooks that feed it. | -| [`inline-rename-input.tsx`](inline-rename-input.tsx) | `` + Save/Cancel addon with Enter/Escape keybindings. | +| [`inline-edit/`](inline-edit/) | Generic inline-edit input (Save/Cancel addons, Enter/Escape) plus the paired `useInlineEdit` state machine. | ## Hooks @@ -53,7 +53,7 @@ that every list reuses. | `usePagination` | `@/hooks/` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. | | `useNavigation` | `detail-navigation/` (internal) | props | no | Pure computation of Prev/Next around a `currentId`. | | `useDetailNavigation` | `detail-navigation/` | URL + props | no | Bundles the three above into a single hook for detail pages. | -| `useInlineEditTitle` | `@/hooks/` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). | +| `useInlineEdit` | `inline-edit/` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). | | `usePageStorageKeys` | `@/hooks/` | router | no | Resolves the three per-page storage keys reactively. | ## Library helpers (in `@/lib/`) @@ -146,7 +146,7 @@ and deletes them. - `vitest run` covers the pure utilities (`table-filter`, `table-state`, `view-options-storage`, `url-params`, `table-sort`), the hook behaviours - (`use-pagination`, `use-table-query-filter`, `use-inline-edit-title`, + (`use-pagination`, `use-table-query-filter`, `use-inline-edit`, `use-page-storage-keys`, `use-detail-navigation`), and the components (`detail-navigation/`, `data-table`). - jsdom doesn't ship `Element.prototype.scrollIntoView` or `ResizeObserver` diff --git a/frontend/src/components/shared/inline-edit/index.ts b/frontend/src/components/shared/inline-edit/index.ts new file mode 100644 index 00000000..1c809b78 --- /dev/null +++ b/frontend/src/components/shared/inline-edit/index.ts @@ -0,0 +1,2 @@ +export { InlineEditInput } from './inline-edit-input'; +export { useInlineEdit } from './use-inline-edit'; diff --git a/frontend/src/components/shared/inline-rename-input.tsx b/frontend/src/components/shared/inline-edit/inline-edit-input.tsx similarity index 69% rename from frontend/src/components/shared/inline-rename-input.tsx rename to frontend/src/components/shared/inline-edit/inline-edit-input.tsx index 1f8bcb8e..bee5a60f 100644 --- a/frontend/src/components/shared/inline-rename-input.tsx +++ b/frontend/src/components/shared/inline-edit/inline-edit-input.tsx @@ -4,18 +4,7 @@ import { type KeyboardEvent, type Ref } from 'react'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; import { cn } from '@/lib/utils'; -/** - * Default upper bound on title length. Chosen as a balance between letting - * users name things expressively and preventing accidental paste-bombs that - * would break truncation in tables and breadcrumbs. Backend columns are - * unbounded `varchar`, so this is purely a UX guard — the HTML `maxLength` - * attribute below silently stops further typing rather than rejecting the - * existing value (which lets existing records that exceed this limit still - * be edited and re-saved without surprise validation errors). - */ -export const INLINE_RENAME_MAX_LENGTH = 200; - -interface InlineRenameInputProps { +interface InlineEditInputProps { /** * Auto-focus the input on mount. Needed for table-cell call sites that * switch into edit mode in-place: the parent flips a flag (e.g. @@ -29,14 +18,15 @@ interface InlineRenameInputProps { className?: string; /** Initial value rendered inside the uncontrolled input. */ defaultValue?: string; - /** Ref to the underlying `` element. Pair with `useInlineEditTitle().inputRef`. */ + /** Ref to the underlying `` element. Pair with `useInlineEdit().inputRef`. */ inputRef?: Ref; /** * Max length applied via the native HTML `maxLength` attribute. Defaults - * to {@link INLINE_RENAME_MAX_LENGTH}. The browser stops typing past the - * limit without altering programmatically-set `defaultValue`, so this is - * a UX guard, not a hard validation gate. Override only when a specific - * surface has a stricter or looser constraint. + * to a UX-safe `200` to prevent accidental paste-bombs that would break + * truncation in tables and breadcrumbs. Override per call site when a + * stricter or looser constraint applies; the browser silently stops + * typing past the limit without altering programmatically-set + * `defaultValue`, so this is a guard rather than a validation gate. */ maxLength?: number; onCancel: () => void; @@ -50,29 +40,30 @@ interface InlineRenameInputProps { } /** - * Inline rename input used inside table cells and detail-page breadcrumbs. + * Generic inline-edit input used inside table cells and detail-page + * breadcrumbs (rename flows, quick-create entries, in-place note edits). * - * Pairs with {@link useInlineEditTitle} — the parent owns the open/close - * state and supplies a ref via that hook; this component owns the - * presentation (input + Save/Cancel addon buttons), keyboard semantics - * (`Enter` saves, `Escape` cancels), and the loading spinner during save. + * Pairs with {@link useInlineEdit} — the parent owns the open/close state + * and supplies a ref via that hook; this component owns the presentation + * (input + Save/Cancel addon buttons), keyboard semantics (`Enter` saves, + * `Escape` cancels), and the loading spinner during save. * * The input is uncontrolled (`defaultValue`) to match the pattern across * the codebase: callers read the value at submit time from `inputRef.current`, * not from React state, which avoids a re-render per keystroke for a value * that's only relevant once. */ -export const InlineRenameInput = ({ +export const InlineEditInput = ({ autoFocus = false, busy = false, className, defaultValue, inputRef, - maxLength = INLINE_RENAME_MAX_LENGTH, + maxLength = 200, onCancel, onSave, placeholder, -}: InlineRenameInputProps) => { +}: InlineEditInputProps) => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); diff --git a/frontend/src/hooks/use-inline-edit-title.test.tsx b/frontend/src/components/shared/inline-edit/use-inline-edit.test.tsx similarity index 85% rename from frontend/src/hooks/use-inline-edit-title.test.tsx rename to frontend/src/components/shared/inline-edit/use-inline-edit.test.tsx index ed6261ff..e50e505f 100644 --- a/frontend/src/hooks/use-inline-edit-title.test.tsx +++ b/frontend/src/components/shared/inline-edit/use-inline-edit.test.tsx @@ -1,16 +1,16 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useInlineEditTitle } from './use-inline-edit-title'; +import { useInlineEdit } from './use-inline-edit'; -describe('useInlineEditTitle', () => { +describe('useInlineEdit', () => { it('starts in non-editing state', () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); expect(result.current.isEditing).toBe(false); }); it('startEdit flips to editing, stopEdit flips back', () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); act(() => result.current.startEdit()); expect(result.current.isEditing).toBe(true); @@ -20,7 +20,7 @@ describe('useInlineEditTitle', () => { }); it('exits edit mode when resetKey changes', () => { - const { rerender, result } = renderHook(({ resetKey }) => useInlineEditTitle({ resetKey }), { + const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), { initialProps: { resetKey: 'a' as null | string | undefined }, }); @@ -32,7 +32,7 @@ describe('useInlineEditTitle', () => { }); it('keeps edit mode across rerenders with the same resetKey', () => { - const { rerender, result } = renderHook(({ resetKey }) => useInlineEditTitle({ resetKey }), { + const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), { initialProps: { resetKey: 'a' as null | string | undefined }, }); @@ -42,7 +42,7 @@ describe('useInlineEditTitle', () => { }); it('treats `null` and `undefined` as distinct keys (state reset on transition)', () => { - const { rerender, result } = renderHook(({ resetKey }) => useInlineEditTitle({ resetKey }), { + const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), { initialProps: { resetKey: null as null | string | undefined }, }); @@ -56,12 +56,12 @@ describe('useInlineEditTitle', () => { }); it('returns a ref object whose .current starts at null', () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); expect(result.current.inputRef.current).toBeNull(); }); it('handleDropdownCloseAutoFocus prevents default while editing', () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); act(() => result.current.startEdit()); @@ -74,7 +74,7 @@ describe('useInlineEditTitle', () => { }); it('handleDropdownCloseAutoFocus is a no-op when not editing', () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); const event = new Event('autofocus', { cancelable: true }); result.current.handleDropdownCloseAutoFocus(event); @@ -83,7 +83,7 @@ describe('useInlineEditTitle', () => { }); it('focuses and selects the input on the next animation frame after startEdit', async () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); const input = document.createElement('input'); input.value = 'hello'; @@ -109,7 +109,7 @@ describe('useInlineEditTitle', () => { }); it('cancels the pending focus when isEditing flips off before the frame fires', async () => { - const { result } = renderHook(() => useInlineEditTitle({ resetKey: 'a' })); + const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' })); const input = document.createElement('input'); document.body.appendChild(input); diff --git a/frontend/src/hooks/use-inline-edit-title.ts b/frontend/src/components/shared/inline-edit/use-inline-edit.ts similarity index 89% rename from frontend/src/hooks/use-inline-edit-title.ts rename to frontend/src/components/shared/inline-edit/use-inline-edit.ts index 621f5dd2..c3a051b9 100644 --- a/frontend/src/hooks/use-inline-edit-title.ts +++ b/frontend/src/components/shared/inline-edit/use-inline-edit.ts @@ -2,7 +2,7 @@ import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; -interface UseInlineEditTitleOptions { +interface UseInlineEditOptions { /** * When this value changes, the edit session is reset (closes any open * input). Use it for the entity id that owns the editor — navigating @@ -11,7 +11,7 @@ interface UseInlineEditTitleOptions { resetKey?: null | string | undefined; } -interface UseInlineEditTitleResult { +interface UseInlineEditResult { /** * Spread onto a Radix `` (or any component with the * same `onCloseAutoFocus` semantics) when the dropdown contains a button @@ -29,10 +29,10 @@ interface UseInlineEditTitleResult({ +export const useInlineEdit = ({ resetKey, -}: UseInlineEditTitleOptions = {}): UseInlineEditTitleResult => { +}: UseInlineEditOptions = {}): UseInlineEditResult => { const [isEditing, setIsEditing] = useState(false); const inputRef = useRef(null); diff --git a/frontend/src/features/knowledges/knowledge-header.tsx b/frontend/src/features/knowledges/knowledge-header.tsx index 3f5bccb4..514597d2 100644 --- a/frontend/src/features/knowledges/knowledge-header.tsx +++ b/frontend/src/features/knowledges/knowledge-header.tsx @@ -9,7 +9,7 @@ import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; import { DetailNavigationToolbar } from '@/components/shared/detail-navigation'; -import { InlineRenameInput } from '@/components/shared/inline-rename-input'; +import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -23,7 +23,6 @@ import { import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useInlineEditTitle } from '@/hooks/use-inline-edit-title'; import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider'; import { useKnowledgeDetailNavigation } from './use-knowledge-detail-navigation'; @@ -65,7 +64,7 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu isEditing: isEditingTitle, startEdit: handleRenameStart, stopEdit: handleRenameCancel, - } = useInlineEditTitle({ resetKey: knowledgeId }); + } = useInlineEdit({ resetKey: knowledgeId }); const handleRenameSave = useCallback(async () => { const newQuestion = editingInputRef.current?.value.trim(); @@ -132,7 +131,7 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu {isEditingTitle && canShowActions ? ( - { isEditing: isEditingTitle, startEdit: handleFlowRenameStart, stopEdit: handleFlowRenameCancel, - } = useInlineEditTitle({ resetKey: flowId }); + } = useInlineEdit({ resetKey: flowId }); const [isFinishing, setIsFinishing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -308,7 +307,7 @@ const Flow = () => { )} {isEditingTitle && flow ? ( - { if (isEditing) { return (
e.stopPropagation()}> - { if (isEditing) { return (
e.stopPropagation()}> - { isEditing: isEditingTitle, startEdit: handleTemplateRenameStart, stopEdit: handleTemplateRenameCancel, - } = useInlineEditTitle({ resetKey: templateId }); + } = useInlineEdit({ resetKey: templateId }); // Fetch template data when editing const { data: templateData, loading: isLoadingTemplate } = useFlowTemplateQuery({ @@ -404,7 +403,7 @@ const Template = () => { {isEditingTitle && canShowActions ? ( - { if (isEditing) { return (
e.stopPropagation()}> -