refactor(frontend): consolidate inline-edit module and drop the rename framing

Group inline-rename-input.tsx and use-inline-edit-title.ts into a new
components/shared/inline-edit/ folder, the same pattern we applied to
detail-navigation, overwrite, and unsaved-changes. Rename to drop the
misleading "rename" framing — the API has nothing rename-specific
(autoFocus, busy, defaultValue, onCancel, onSave) and the same component
is used for quick-create and inline-edit flows too:
  InlineRenameInput → InlineEditInput
  useInlineEditTitle → useInlineEdit
  INLINE_RENAME_MAX_LENGTH → inlined as default `maxLength = 200`
This commit is contained in:
Sergey Kozyrenko
2026-05-15 11:41:52 +07:00
parent c75335eac9
commit f46de8d374
11 changed files with 55 additions and 65 deletions
+3 -3
View File
@@ -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) | `<input>` + 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`
@@ -0,0 +1,2 @@
export { InlineEditInput } from './inline-edit-input';
export { useInlineEdit } from './use-inline-edit';
@@ -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 `<input>` element. Pair with `useInlineEditTitle().inputRef`. */
/** Ref to the underlying `<input>` element. Pair with `useInlineEdit().inputRef`. */
inputRef?: Ref<HTMLInputElement>;
/**
* 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<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -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);
@@ -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<TElement extends HTMLElement = HTMLInputElement> {
interface UseInlineEditResult<TElement extends HTMLElement = HTMLInputElement> {
/**
* Spread onto a Radix `<DropdownMenuContent>` (or any component with the
* same `onCloseAutoFocus` semantics) when the dropdown contains a button
@@ -29,10 +29,10 @@ interface UseInlineEditTitleResult<TElement extends HTMLElement = HTMLInputEleme
}
/**
* Shared state machine for "double-click to rename" headers / table cells.
* Shared state machine for inline-edit surfaces (double-click to rename,
* quick-add, in-place note edits).
*
* Combines four micro-responsibilities that every renameable surface in the
* app needs:
* Combines four micro-responsibilities that every editable surface needs:
* - `isEditing` boolean + start/stop helpers,
* - a ref for the inline input,
* - deferred focus + select-all on the next animation frame, so the focus
@@ -44,9 +44,9 @@ interface UseInlineEditTitleResult<TElement extends HTMLElement = HTMLInputEleme
* Pass the entity id as `resetKey` so navigation between items closes any
* stale editor automatically.
*/
export const useInlineEditTitle = <TElement extends HTMLElement = HTMLInputElement>({
export const useInlineEdit = <TElement extends HTMLElement = HTMLInputElement>({
resetKey,
}: UseInlineEditTitleOptions = {}): UseInlineEditTitleResult<TElement> => {
}: UseInlineEditOptions = {}): UseInlineEditResult<TElement> => {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<null | TElement>(null);
@@ -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
<BreadcrumbItem className="gap-2">
<LibraryBig className="size-4 shrink-0" />
{isEditingTitle && canShowActions ? (
<InlineRenameInput
<InlineEditInput
busy={isRenaming}
className="w-64 max-w-full"
defaultValue={knowledgeName ?? ''}
+3 -4
View File
@@ -22,7 +22,7 @@ import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
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';
@@ -43,7 +43,6 @@ import { useFlowDetailNavigation } from '@/features/flows/use-flow-detail-naviga
import { ResultType, StatusType, useRenameFlowMutation } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
import { useInlineEditTitle } from '@/hooks/use-inline-edit-title';
import { Log } from '@/lib/log';
import { copyToClipboard, downloadTextFile, generateFileName, generateReport } from '@/lib/report';
import { formatName } from '@/lib/utils/format';
@@ -190,7 +189,7 @@ const Flow = () => {
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 ? (
<InlineRenameInput
<InlineEditInput
busy={isRenameLoading}
className="w-64 max-w-full"
defaultValue={flowTitle}
+2 -2
View File
@@ -12,7 +12,7 @@ import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { InlineEditInput } 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';
@@ -207,7 +207,7 @@ const Flows = () => {
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<InlineRenameInput
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={title}
+2 -2
View File
@@ -9,7 +9,7 @@ import type { BadgeVariant } from '@/components/ui/badge';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { InlineEditInput } 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';
@@ -191,7 +191,7 @@ const Knowledges = () => {
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<InlineRenameInput
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={question}
+3 -4
View File
@@ -19,7 +19,7 @@ import { z } from 'zod';
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 { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -42,7 +42,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useTemplateDetailNavigation } from '@/features/templates/use-template-detail-navigation';
import { useFlowTemplateQuery } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useInlineEditTitle } from '@/hooks/use-inline-edit-title';
import { cn } from '@/lib/utils';
import { type Template, useTemplates } from '@/providers/templates-provider';
@@ -258,7 +257,7 @@ const Template = () => {
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 = () => {
<BreadcrumbList>
<BreadcrumbItem className="gap-2">
{isEditingTitle && canShowActions ? (
<InlineRenameInput
<InlineEditInput
busy={isRenaming}
className="w-64 max-w-full"
defaultValue={templateName ?? ''}
+2 -2
View File
@@ -7,7 +7,7 @@ import { toast } from 'sonner';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineRenameInput } from '@/components/shared/inline-rename-input';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
@@ -124,7 +124,7 @@ const Templates = () => {
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<InlineRenameInput
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={title}