diff --git a/.changeset/flow-palette-followups.md b/.changeset/flow-palette-followups.md new file mode 100644 index 000000000..034227da2 --- /dev/null +++ b/.changeset/flow-palette-followups.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': minor +--- + +Flow designer add-node palette follow-ups (#1943): localize the category section headings (Data / Logic / Human / Integration / Flow) to the active console language, and upgrade the "Recently used" list from browser-local storage to per-user cloud sync via `sys_user_preference` (new `FlowPaletteRecentsProvider` / `useFlowPaletteRecents`), with one-shot migration of the legacy localStorage key and a localStorage fallback when offline or outside a provider. Adds a Flow Designer guide to the docs. diff --git a/content/docs/guide/flow-designer.md b/content/docs/guide/flow-designer.md new file mode 100644 index 000000000..78381c4fa --- /dev/null +++ b/content/docs/guide/flow-designer.md @@ -0,0 +1,126 @@ +--- +title: "Flow Designer" +description: "Authoring automation flows on the visual canvas — nodes, edges, the searchable add-node palette, the node inspector, validation, and simulation." +--- + +# Flow Designer + +The **Flow Designer** is the visual editor for `flow` metadata in the console's +metadata admin. It renders a flow's nodes and edges on a pan/zoom canvas so you +can assemble automation — create/update records, branch on conditions, wait for +events, call APIs, route for approval — without hand-editing JSON. The designer +is a thin, **spec-driven** view over `flow` metadata: everything you drop on the +canvas is a node in the flow's schema, so the same document runs unchanged on +the automation engine. + +Open it from a `flow` record's **Design** tab. The **Form** tab holds the same +flow as an editable tree; the two stay in sync. + +## The canvas + +Each node is a card showing its icon, label, `type`, and a one-line summary of +its config. Edges are the arrows between them. + +| Gesture | Result | +|---|---| +| Click a card | Select it (opens the node inspector) | +| Drag a card | Reposition it | +| Hover a card → click the bottom **+** | Append a connected child node | +| Zoom controls / **Fit** | Scale and re-center the graph | + +Structural problems (an undeclared cycle, an unreachable node) surface three +ways at once: a red ring on the offending card, a badge in its corner, and an +inline banner at the top-left of the canvas. Clicking a banner row selects and +pans to the element it refers to. + +## Adding nodes — the palette + +The **Add node** button (top-right, edit mode only) opens the node palette: a +searchable, grouped list of every node type the flow can use. + +### Search and keyboard + +Type in the box at the top to filter across **all** categories at once — the +match is a case-insensitive substring test over each node's **label**, **hint**, +and **type**, so `scr` finds *Screen*, `http` finds *HTTP request*, and a word +that only appears in a hint (`concurrently` → *Parallel*) still surfaces the +node. Clearing the box restores the full grouped list. + +The palette is keyboard-navigable end to end: + +| Key | Action | +|---|---| +| `↑` / `↓` | Move the highlight (wraps around) | +| `Enter` | Insert the highlighted node | +| `Esc` | Close the palette | + +The search box autofocuses when the palette opens, so you can open, type a few +letters, and press `Enter` without touching the mouse. + +### Categories + +Nodes are grouped into five sections, in this order: + +| Category | Node types | +|---|---| +| **Data** | Create / Update / Get / Delete record | +| **Logic** | Decision, Loop, Set variables, Parallel, Try / Catch | +| **Human** | Approval, Screen | +| **Integration** | HTTP request, Connector, Script | +| **Flow** | Subflow, Wait, End | + +Section headings are localized to the active console language (e.g. *数据 / +逻辑 / 人工 / 集成 / 流程* in Chinese); the underlying node types are unchanged. + +### Recently used + +When the search box is empty, a **Recently used** group tops the list with the +node types you inserted most recently (up to five, most-recent first) — so the +nodes you reach for repeatedly stop needing a scroll or a search. The list is +per-user and, when the console is connected to a backend, syncs across devices +(see [User-Scoped State Persistence](/docs/guide/user-state-persistence)); it +falls back to browser-local storage when offline. Types from a plugin that was +since uninstalled drop out of the list automatically. + +### Server-merged, plugin-extensible + +The palette is **server-driven**. Beyond the built-in node types, the running +engine publishes its registered actions at `GET /api/v1/automation/actions`, and +plugins contribute their own node types there (for example an `approval` node +from an approvals plugin, or third-party `connector_action` providers). The +designer overlays those descriptors onto the built-in list — adopting the +engine's labels and descriptions and appending engine-only types — so the +palette always matches what the connected backend actually supports. Plugin +nodes are searchable exactly like built-ins, including by their registered +`type`. When the endpoint is unreachable the designer falls back to its +built-in defaults, so authoring still works offline. + +## The node inspector + +Selecting a node opens its inspector: **ID**, **Label**, **Node Type**, an +optional **Description**, and a **Configuration** section. New nodes start with +spec-valid defaults (a *Wait* node already carries a timer config, an *HTTP* +node defaults to `GET`) so a freshly dropped block is never in a broken +intermediate state. + +For node types whose engine executor publishes a `configSchema` (ADR-0018), the +inspector renders a **server-driven property form** from that schema — so a +plugin's node gets a real config UI without the designer hardcoding its fields. + +## Validate, simulate, inspect runs + +The toolbar toggles four side panels: + +| Panel | What it shows | +|---|---| +| **Variables** | The flow's declared variables | +| **Problems** | Structural + server validation issues, each clickable to reveal on canvas | +| **Debug** | A step-through **simulator** that walks the graph and highlights the active / visited nodes | +| **Runs** | Execution history for the published flow, fetched from the engine | + +## See also + +- [User-Scoped State Persistence](/docs/guide/user-state-persistence) — how the + "Recently used" list is stored and synced. +- [Console App](/docs/guide/console) — the reference app that hosts the metadata + admin and its designers. diff --git a/content/docs/guide/meta.json b/content/docs/guide/meta.json index d94007c0a..81280e19c 100644 --- a/content/docs/guide/meta.json +++ b/content/docs/guide/meta.json @@ -24,6 +24,7 @@ "slotted-pages", "console", "console-architecture", + "flow-designer", "metadata-diagnostics", "user-state-persistence", "objectos-integration", diff --git a/content/docs/guide/user-state-persistence.md b/content/docs/guide/user-state-persistence.md index 84476f628..6ee402b40 100644 --- a/content/docs/guide/user-state-persistence.md +++ b/content/docs/guide/user-state-persistence.md @@ -2,7 +2,7 @@ title: "User-Scoped State Persistence" --- -Object UI keeps two pieces of per-user UI state — **Favorites** (pinned apps, starred records, and pinned navigation entries) and **Recent Items** (last visited entities) — alive across reloads, devices, and accounts. The persistence layer is **backend-agnostic**: drop in an adapter, or just let it run on localStorage. +Object UI keeps per-user UI state — **Favorites** (pinned apps, starred records, and pinned navigation entries), **Recent Items** (last visited entities), and **Flow-palette recents** (node types recently added in the flow designer) — alive across reloads, devices, and accounts. The persistence layer is **backend-agnostic**: drop in an adapter, or just let it run on localStorage. ## Design @@ -16,21 +16,21 @@ export interface UserDataAdapter { save(items: T[]): Promise; } -export type UserStateKind = 'favorites' | 'recent'; +export type UserStateKind = 'favorites' | 'recent' | 'flowPaletteRecents'; ``` Three layers, top-down: | Layer | Package | Role | |---|---|---| -| **Providers** (`FavoritesProvider`, `RecentItemsProvider`) | `@object-ui/app-shell` | Own the state, debounce writes, scope storage by user. | +| **Providers** (`FavoritesProvider`, `RecentItemsProvider`, `FlowPaletteRecentsProvider`) | `@object-ui/app-shell` | Own the state, debounce writes, scope storage by user. | | **Adapter registry** (`UserStateAdaptersProvider`) | `@object-ui/app-shell` | Lets a bridge component inject adapters at runtime once `dataSource` + `user.id` are available. | | **Adapters** (`createObjectStackUserStateAdapter`, your own) | `@object-ui/data-objectstack`, custom | Translate `load/save` into HTTP / GraphQL / ObjectQL calls. | ### Three guarantees 1. **localStorage-first.** First paint never blocks on the network. If no adapter is attached, persistence is purely local. -2. **Scoped per `user.id`.** Storage key is `objectui-favorites:u:` (and `objectui-recent-items:u:`). Two accounts on the same browser never see each other's state. +2. **Scoped per `user.id`.** Storage key is `objectui-favorites:u:` (and `objectui-recent-items:u:`, `flow-palette-recents:u:`). Two accounts on the same browser never see each other's state. 3. **Silent degrade.** Adapters must never throw. A 404 / network error means "behave like there is no backend"; the UI keeps working from localStorage. ## Provider tree @@ -179,11 +179,12 @@ Each provider keeps a monotonic `hydrationToken`. If the user switches accounts | `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds }` | | `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins }`. | | `useRecentItems()` | `@object-ui/app-shell` | `{ recentItems, addRecentItem, clearRecentItems }` | +| `useFlowPaletteRecents()` | `@object-ui/app-shell` | `{ recents, recordRecent }` — flow-designer add-node MRU; falls back to localStorage outside a provider. | | `createObjectStackUserStateAdapter(opts)` | `@object-ui/data-objectstack` | Official adapter against the `user_app_state` object. | ## Limits -- **20** favorites and **20** nav-pins per user (independent buckets), **8** recent items. Enforced by the providers; older entries roll off within their own bucket without evicting the other. +- **20** favorites and **20** nav-pins per user (independent buckets), **8** recent items, **5** flow-palette recents. Enforced by the providers; older entries roll off within their own bucket without evicting the other. - One JSON blob per (user, kind). Not designed for high-frequency / large payloads — this is UI state, not data. - No automatic cross-tab sync today (see the roadmap). diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 8bc782e6b..f0570c95c 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -24,6 +24,7 @@ import { PreviewModeProvider } from '../preview/PreviewModeContext'; import { NavigationProvider } from '../context/NavigationContext'; import { FavoritesProvider } from '../context/FavoritesProvider'; import { RecentItemsProvider } from '../context/RecentItemsProvider'; +import { FlowPaletteRecentsProvider } from '../context/FlowPaletteRecentsProvider'; import { UserStateAdaptersProvider, useAttachUserStateAdapters, @@ -83,9 +84,11 @@ export function ConsoleShell({ children }: { children: ReactNode }) { - }>{children} - {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} - + + }>{children} + {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} + + @@ -175,6 +178,7 @@ function UserStateBridge() { if (!user?.id || !dataSource) { attach('favorites', null); attach('recent', null); + attach('flowPaletteRecents', null); return; } const favorites = createObjectStackUserStateAdapter({ @@ -187,11 +191,18 @@ function UserStateBridge() { userId: user.id, key: 'ui.recent', }); + const flowPaletteRecents = createObjectStackUserStateAdapter({ + dataSource, + userId: user.id, + key: 'ui.flow.palette.recents', + }); attach('favorites', favorites); attach('recent', recent); + attach('flowPaletteRecents', flowPaletteRecents); return () => { attach('favorites', null); attach('recent', null); + attach('flowPaletteRecents', null); }; }, [user?.id, dataSource, attach]); diff --git a/packages/app-shell/src/context/FlowPaletteRecentsProvider.tsx b/packages/app-shell/src/context/FlowPaletteRecentsProvider.tsx new file mode 100644 index 000000000..0fb65002e --- /dev/null +++ b/packages/app-shell/src/context/FlowPaletteRecentsProvider.tsx @@ -0,0 +1,229 @@ +/** + * FlowPaletteRecentsProvider + * + * Per-user "recently used" node types for the flow designer's add-node palette + * (objectui#1943). Upgrades the original localStorage-only MRU + * (`previews/flowPaletteRecents.ts`) to cloud-synced-per-user state, mirroring + * `RecentItemsProvider` / `FavoritesProvider`: + * + * - localStorage-first: instant first paint, works offline / pre-auth. + * - Hydrates from `UserDataAdapter` when one is attached (by + * `ConsoleShell`'s bridge); remote is cross-device truth when present. + * - Writes are debounced and pushed to the adapter; localStorage stays in + * sync as a cold-start cache; storage key is scoped by `user.id`. + * - One-shot migration folds the legacy unscoped `flow-palette-recents` key + * (written by the localStorage-only version) into the synced list. + * + * `NodePalette` consumes this via {@link useFlowPaletteRecents}, which falls + * back to the localStorage module when rendered outside a provider (unit + * tests, the dev preview gallery) — preserving the pre-cloud-sync behaviour. + * + * @module + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useAuth } from '@object-ui/auth'; +import { + createDebouncedFlush, + scopedKey, + useStorageSync, + useUserStateAdapter, +} from './UserStateAdapters'; +import { + readPaletteRecents, + recordPaletteRecent, + MAX_PALETTE_RECENTS, + PALETTE_RECENTS_KEY, +} from '../views/metadata-admin/previews/flowPaletteRecents'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface FlowPaletteRecentsValue { + /** Node types, most-recent-first (already capped). */ + recents: string[]; + /** Record a node type as just-used (dedupes to the front, caps the list). */ + recordRecent: (type: string) => void; +} + +// --------------------------------------------------------------------------- +// Storage helpers (the base key matches the legacy localStorage-only module, +// so an unauthenticated session shares one store with the fallback path). +// --------------------------------------------------------------------------- + +const STORAGE_BASE_KEY = PALETTE_RECENTS_KEY; + +function sanitize(list: unknown): string[] { + return Array.isArray(list) + ? list.filter((t): t is string => typeof t === 'string').slice(0, MAX_PALETTE_RECENTS) + : []; +} + +function loadLocal(userId?: string | null): string[] { + try { + const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId)); + return raw ? sanitize(JSON.parse(raw)) : []; + } catch { + return []; + } +} + +function saveLocal(list: string[], userId?: string | null) { + try { + localStorage.setItem(scopedKey(STORAGE_BASE_KEY, userId), JSON.stringify(list)); + } catch { + // storage full / privacy mode — recents are a nicety, never an error + } +} + +/** + * One-shot fold of the legacy *unscoped* `flow-palette-recents` key (the + * localStorage-only version, and the no-provider fallback's store) into a + * signed-in user's scoped list. Idempotent: the legacy key is removed once + * consumed. No-op for anonymous sessions, whose scoped key *is* the unscoped + * key — there is nothing to migrate. + */ +function migrateLegacy( + current: string[], + userId?: string | null, +): { list: string[]; didMigrate: boolean } { + if (!userId) return { list: current, didMigrate: false }; + let raw: string | null; + try { + raw = localStorage.getItem(STORAGE_BASE_KEY); + } catch { + return { list: current, didMigrate: false }; + } + if (raw == null) return { list: current, didMigrate: false }; + let legacy: string[] = []; + try { + legacy = sanitize(JSON.parse(raw)); + } catch { + /* corrupt — treat as empty, still clear below */ + } + try { + localStorage.removeItem(STORAGE_BASE_KEY); + } catch { + /* noop */ + } + if (legacy.length === 0) return { list: current, didMigrate: true }; + const merged = [...current]; + for (const t of legacy) if (!merged.includes(t)) merged.push(t); + return { list: merged.slice(0, MAX_PALETTE_RECENTS), didMigrate: true }; +} + +// --------------------------------------------------------------------------- +// Context + Provider +// --------------------------------------------------------------------------- + +const FlowPaletteRecentsContext = createContext(null); + +export function FlowPaletteRecentsProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const userId = user?.id ?? null; + const adapter = useUserStateAdapter('flowPaletteRecents'); + + const [recents, setRecents] = useState(() => loadLocal(userId)); + + // Cross-tab sync — see FavoritesProvider for the rationale. + useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), (value) => { + setRecents(sanitize(value)); + }); + + const flusher = useMemo( + () => + createDebouncedFlush(async (items) => { + if (adapter) await adapter.save(items); + }, 500), + [adapter], + ); + useEffect(() => () => void flusher.flush(), [flusher]); + + const commit = useCallback( + (next: string[]) => { + saveLocal(next, userId); + if (adapter) flusher.schedule(next); + }, + [userId, adapter, flusher], + ); + + // Hydrate (and fold legacy) on mount and whenever the user or attached + // adapter changes. Runs even without an adapter so offline/pre-auth sessions + // still migrate + read localStorage; when the adapter attaches it re-runs and + // remote wins as cross-device truth. + const hydrationToken = useRef(0); + useEffect(() => { + const token = ++hydrationToken.current; + let cancelled = false; + void (async () => { + const local = loadLocal(userId); + const remote = adapter ? sanitize(await adapter.load().catch(() => [])) : []; + if (cancelled || token !== hydrationToken.current) return; + const base = remote.length ? remote : local; + const { list, didMigrate } = migrateLegacy(base, userId); + setRecents(list); + saveLocal(list, userId); + if (didMigrate && adapter) flusher.schedule(list); + })(); + return () => { + cancelled = true; + }; + }, [adapter, userId, flusher]); + + const recordRecent = useCallback( + (type: string) => { + if (!type) return; + setRecents((prev) => { + const next = [type, ...prev.filter((t) => t !== type)].slice(0, MAX_PALETTE_RECENTS); + commit(next); + return next; + }); + }, + [commit], + ); + + const value = useMemo( + () => ({ recents, recordRecent }), + [recents, recordRecent], + ); + + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Access the flow-palette "recently used" list. + * + * Outside a `` (unit tests, the dev preview + * gallery), falls back to the localStorage-only module so the palette keeps + * its pre-cloud-sync behaviour without any provider wiring. + */ +export function useFlowPaletteRecents(): FlowPaletteRecentsValue { + const ctx = useContext(FlowPaletteRecentsContext); + // Fallback state is always declared (rules of hooks) but only used when no + // provider is present. + const [fallback, setFallback] = useState(() => readPaletteRecents()); + const recordFallback = useCallback((type: string) => { + recordPaletteRecent(type); + setFallback(readPaletteRecents()); + }, []); + if (ctx) return ctx; + return { recents: fallback, recordRecent: recordFallback }; +} diff --git a/packages/app-shell/src/context/UserStateAdapters.tsx b/packages/app-shell/src/context/UserStateAdapters.tsx index b57fd15ca..42c027056 100644 --- a/packages/app-shell/src/context/UserStateAdapters.tsx +++ b/packages/app-shell/src/context/UserStateAdapters.tsx @@ -42,11 +42,12 @@ export interface UserDataAdapter { save(items: T[]): Promise; } -export type UserStateKind = 'favorites' | 'recent'; +export type UserStateKind = 'favorites' | 'recent' | 'flowPaletteRecents'; interface UserStateAdaptersValue { favorites: UserDataAdapter | null; recent: UserDataAdapter | null; + flowPaletteRecents: UserDataAdapter | null; } interface AttachApi { @@ -57,7 +58,11 @@ interface AttachApi { // Contexts (split read / write so consumers don't re-render unnecessarily) // --------------------------------------------------------------------------- -const ReadCtx = createContext({ favorites: null, recent: null }); +const ReadCtx = createContext({ + favorites: null, + recent: null, + flowPaletteRecents: null, +}); const WriteCtx = createContext(null); // --------------------------------------------------------------------------- @@ -68,6 +73,7 @@ export function UserStateAdaptersProvider({ children }: { children: ReactNode }) const [adapters, setAdapters] = useState({ favorites: null, recent: null, + flowPaletteRecents: null, }); // Stable attach API so bridge useEffect deps don't churn. diff --git a/packages/app-shell/src/context/__tests__/FlowPaletteRecentsProvider.test.tsx b/packages/app-shell/src/context/__tests__/FlowPaletteRecentsProvider.test.tsx new file mode 100644 index 000000000..fefbac857 --- /dev/null +++ b/packages/app-shell/src/context/__tests__/FlowPaletteRecentsProvider.test.tsx @@ -0,0 +1,154 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const mockUser = { current: { id: 'user-1', name: 'Alice', email: 'a@x' } as any | null }; +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: mockUser.current, isAuthenticated: !!mockUser.current, isLoading: false }), +})); + +import { + FlowPaletteRecentsProvider, + useFlowPaletteRecents, +} from '../FlowPaletteRecentsProvider'; +import { + UserStateAdaptersProvider, + useAttachUserStateAdapters, + type UserDataAdapter, +} from '../UserStateAdapters'; + +function makeAdapter(initial: string[] = []) { + const loadMock = vi.fn().mockResolvedValue(initial); + const saveMock = vi.fn().mockResolvedValue(undefined); + return { load: loadMock, save: saveMock, loadMock, saveMock } as UserDataAdapter & { + loadMock: ReturnType; + saveMock: ReturnType; + }; +} + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function useHarness() { + const attach = useAttachUserStateAdapters(); + const recents = useFlowPaletteRecents(); + return { attach, recents }; +} + +describe('FlowPaletteRecentsProvider', () => { + beforeEach(() => { + localStorage.clear(); + mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; + }); + + it('hydrates synchronously from user-scoped localStorage on mount', () => { + localStorage.setItem('flow-palette-recents:u:user-1', JSON.stringify(['screen', 'http'])); + const { result } = renderHook(() => useFlowPaletteRecents(), { wrapper }); + expect(result.current.recents).toEqual(['screen', 'http']); + }); + + it('recordRecent moves the type to the front, deduped', () => { + const { result } = renderHook(() => useFlowPaletteRecents(), { wrapper }); + act(() => result.current.recordRecent('a')); + act(() => result.current.recordRecent('b')); + act(() => result.current.recordRecent('a')); + expect(result.current.recents).toEqual(['a', 'b']); + }); + + it('caps the list at 5', () => { + const { result } = renderHook(() => useFlowPaletteRecents(), { wrapper }); + act(() => { + for (let i = 0; i < 8; i++) result.current.recordRecent(`t-${i}`); + }); + expect(result.current.recents).toHaveLength(5); + expect(result.current.recents[0]).toBe('t-7'); + }); + + it('persists to user-scoped localStorage only', () => { + const { result } = renderHook(() => useFlowPaletteRecents(), { wrapper }); + act(() => result.current.recordRecent('screen')); + // The unscoped legacy key is never written for a signed-in user. + expect(localStorage.getItem('flow-palette-recents')).toBeNull(); + expect(JSON.parse(localStorage.getItem('flow-palette-recents:u:user-1') || '[]')).toEqual([ + 'screen', + ]); + }); + + it('migrates the legacy unscoped key into the scoped list and clears it', async () => { + // Written by the pre-cloud-sync localStorage-only version. + localStorage.setItem('flow-palette-recents', JSON.stringify(['legacy1', 'legacy2'])); + const { result } = renderHook(() => useFlowPaletteRecents(), { wrapper }); + await waitFor(() => { + expect(result.current.recents).toEqual(['legacy1', 'legacy2']); + }); + // Legacy key consumed + removed; scoped key now holds the data. + expect(localStorage.getItem('flow-palette-recents')).toBeNull(); + expect(JSON.parse(localStorage.getItem('flow-palette-recents:u:user-1') || '[]')).toEqual([ + 'legacy1', + 'legacy2', + ]); + }); + + it('hydrates from the adapter and overrides localStorage state', async () => { + localStorage.setItem('flow-palette-recents:u:user-1', JSON.stringify(['local'])); + const adapter = makeAdapter(['remote1', 'remote2']); + + const { result } = renderHook(() => useHarness(), { wrapper }); + expect(result.current.recents.recents).toEqual(['local']); + + act(() => result.current.attach('flowPaletteRecents', adapter)); + + await waitFor(() => { + expect(result.current.recents.recents).toEqual(['remote1', 'remote2']); + }); + expect(JSON.parse(localStorage.getItem('flow-palette-recents:u:user-1') || '[]')).toEqual([ + 'remote1', + 'remote2', + ]); + }); + + it('debounces adapter.save() across a burst of picks', async () => { + const adapter = makeAdapter([]); + const { result } = renderHook(() => useHarness(), { wrapper }); + + act(() => result.current.attach('flowPaletteRecents', adapter)); + await waitFor(() => expect(adapter.loadMock).toHaveBeenCalled()); + adapter.saveMock.mockClear(); + + act(() => { + result.current.recents.recordRecent('1'); + result.current.recents.recordRecent('2'); + result.current.recents.recordRecent('3'); + }); + + expect(adapter.saveMock).not.toHaveBeenCalled(); + await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 }); + expect(adapter.saveMock.mock.calls[0][0]).toEqual(['3', '2', '1']); + }); + + it('falls back to the localStorage module outside a provider', () => { + localStorage.setItem('flow-palette-recents', JSON.stringify(['fallback'])); + const { result } = renderHook(() => useFlowPaletteRecents()); + expect(result.current.recents).toEqual(['fallback']); + act(() => result.current.recordRecent('new')); + expect(result.current.recents).toEqual(['new', 'fallback']); + // Fallback writes the unscoped legacy/localStorage key. + expect(JSON.parse(localStorage.getItem('flow-palette-recents') || '[]')).toEqual([ + 'new', + 'fallback', + ]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index fc1938b25..3fb49379f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -507,6 +507,13 @@ const ENGINE_STRINGS_EN: Record = { 'engine.flowPalette.search': 'Search nodes…', 'engine.flowPalette.empty': 'No matching nodes.', 'engine.flowPalette.recent': 'Recently used', + // Palette section headings (localized display; the canonical NodeCategory + // literals stay English as in-memory bucket keys — see flow-canvas-parts). + 'engine.flowPalette.category.data': 'Data', + 'engine.flowPalette.category.logic': 'Logic', + 'engine.flowPalette.category.human': 'Human', + 'engine.flowPalette.category.integration': 'Integration', + 'engine.flowPalette.category.flow': 'Flow', // Reorder buttons (used in InspectorShell header) 'engine.inspector.reorder.up': 'Move up', 'engine.inspector.reorder.down': 'Move down', @@ -1810,6 +1817,12 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.flowPalette.search': '搜索节点…', 'engine.flowPalette.empty': '没有匹配的节点。', 'engine.flowPalette.recent': '最近使用', + // Palette section headings (localized display only) + 'engine.flowPalette.category.data': '数据', + 'engine.flowPalette.category.logic': '逻辑', + 'engine.flowPalette.category.human': '人工', + 'engine.flowPalette.category.integration': '集成', + 'engine.flowPalette.category.flow': '流程', // Reorder buttons 'engine.inspector.reorder.up': '上移', 'engine.inspector.reorder.down': '下移', diff --git a/packages/app-shell/src/views/metadata-admin/previews/NodePalette.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/NodePalette.test.tsx index eb810ea26..41c03b80a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/NodePalette.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/NodePalette.test.tsx @@ -43,7 +43,9 @@ const ITEMS: PaletteItem[] = [ { type: 'end', label: 'End', hint: 'Terminate the flow', category: 'Flow' }, ]; -function renderPalette(overrides: { onPick?: (type: string) => void; open?: boolean } = {}) { +function renderPalette( + overrides: { onPick?: (type: string) => void; open?: boolean; locale?: string } = {}, +) { const onPick = overrides.onPick ?? vi.fn(); const view = render( void; open?: bool open={overrides.open ?? true} onOpenChange={() => {}} onPick={onPick} + locale={overrides.locale} > , @@ -74,6 +77,18 @@ describe('NodePalette search & filtering', () => { } }); + it('localizes the category headings for the active locale', () => { + renderPalette({ locale: 'zh-CN' }); + // Headings are translated; the canonical English literals are gone. + for (const heading of ['数据', '逻辑', '人工', '集成', '流程']) { + expect(screen.getByText(heading)).toBeTruthy(); + } + expect(screen.queryByText('Data')).toBeNull(); + expect(screen.queryByText('Logic')).toBeNull(); + // Item labels come from the (server-merged) items, not i18n — unchanged. + expect(screen.getByText('Create record')).toBeTruthy(); + }); + it('filters across all categories and hides empty sections', async () => { renderPalette(); // 'record' hits Create record's label (Data) and nothing else. diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx index 851ac149b..536d884b2 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx @@ -49,7 +49,7 @@ import { } from '@object-ui/components'; import { t as tr } from '../i18n'; import { NODE_W, NODE_H, type Point } from './flow-canvas-layout'; -import { readPaletteRecents, recordPaletteRecent } from './flowPaletteRecents'; +import { useFlowPaletteRecents } from '../../../context/FlowPaletteRecentsProvider'; export function nodeIcon(type: string): LucideIcon { switch (type) { @@ -593,19 +593,19 @@ const PALETTE_GROUP_CLASS = */ export function NodePalette({ locale, items = NODE_PALETTE, onPick, open, onOpenChange, children }: NodePaletteProps) { const [q, setQ] = React.useState(''); - // Lazy init covers mounting in the already-open state (tests, controlled use). - const [recents, setRecents] = React.useState(() => (open ? readPaletteRecents() : [])); + // Per-user "recently used" (cloud-synced when a provider is mounted; falls + // back to a localStorage MRU outside one — tests, the dev preview gallery). + const { recents, recordRecent } = useFlowPaletteRecents(); const inputRef = React.useRef(null); // Watching `open` (not onOpenChange) covers both close paths: outside-click/ // Escape fires onOpenChange, but a pick closes via the parent's setState // only. Render-phase adjustment (not an effect) per react.dev's "adjusting - // state when a prop changes"; re-reading recents per open keeps them fresh. + // state when a prop changes" — clear the query when the palette closes. const [prevOpen, setPrevOpen] = React.useState(open); if (open !== prevOpen) { setPrevOpen(open); - if (open) setRecents(readPaletteRecents()); - else setQ(''); + if (!open) setQ(''); } const query = q.trim().toLowerCase(); @@ -649,7 +649,7 @@ export function NodePalette({ locale, items = NODE_PALETTE, onPick, open, onOpen }, [items, recents, query]); const handlePick = (type: string) => { - recordPaletteRecent(type); + recordRecent(type); onPick(type); }; @@ -707,7 +707,11 @@ export function NodePalette({ locale, items = NODE_PALETTE, onPick, open, onOpen )} {grouped.map(([category, list]) => ( - + {list.map((item) => renderItem(item))} ))} diff --git a/packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts b/packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts index 87d10a5a7..143f872b2 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts @@ -13,10 +13,16 @@ * self-heals on the next pick. */ -const KEY = 'flow-palette-recents'; +/** + * localStorage key. Also the base key + legacy-migration source for the + * cloud-synced {@link ../../../context/FlowPaletteRecentsProvider}. + */ +export const PALETTE_RECENTS_KEY = 'flow-palette-recents'; +const KEY = PALETTE_RECENTS_KEY; /** Most-recent-first cap — keeps the group glanceable, not a second palette. */ -const MAX_RECENTS = 5; +export const MAX_PALETTE_RECENTS = 5; +const MAX_RECENTS = MAX_PALETTE_RECENTS; /** Read the MRU list. Returns `[]` on missing/corrupt storage or during SSR. */ export function readPaletteRecents(): string[] {