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()}>
-