From 4da09dd973b0fcb2413e3166be2cd5ab84d6de87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 08:36:38 +0000 Subject: [PATCH 1/2] feat(ui): bounded + live-searchable relationship dropdowns on the edit page Replace the unbounded findMany({}) in item-form preparation with getRelationshipOptions (bounded, take-limited, selected-id seeded). ComboboxField and RelationshipManager gain debounced live search via the relationshipOptions serverAction op, falling back to client-side filtering when no server action is wired. Closes #631 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LVrp6NFf7v2WrxCT7d8i3Z --- .changeset/tame-owls-wander.md | 16 ++ packages/ui/src/components/ItemFormClient.tsx | 2 + .../src/components/fields/ComboboxField.tsx | 45 ++++-- .../src/components/fields/FieldRenderer.tsx | 16 ++ .../components/fields/RelationshipField.tsx | 11 ++ .../components/fields/RelationshipManager.tsx | 54 ++++--- packages/ui/src/lib/prepareItemForm.ts | 43 ++++-- packages/ui/src/lib/useRelationshipSearch.ts | 143 ++++++++++++++++++ .../tests/components/ComboboxField.test.tsx | 124 +++++++++++++++ .../components/RelationshipManager.test.tsx | 127 ++++++++++++++++ packages/ui/tests/lib/prepareItemForm.test.ts | 141 +++++++++++++++++ 11 files changed, 675 insertions(+), 47 deletions(-) create mode 100644 .changeset/tame-owls-wander.md create mode 100644 packages/ui/src/lib/useRelationshipSearch.ts create mode 100644 packages/ui/tests/components/ComboboxField.test.tsx create mode 100644 packages/ui/tests/components/RelationshipManager.test.tsx create mode 100644 packages/ui/tests/lib/prepareItemForm.test.ts diff --git a/.changeset/tame-owls-wander.md b/.changeset/tame-owls-wander.md new file mode 100644 index 00000000..f612b5f1 --- /dev/null +++ b/.changeset/tame-owls-wander.md @@ -0,0 +1,16 @@ +--- +'@opensaas/stack-ui': minor +--- + +Wire the edit page onto the relationship-options primitive so relationship dropdowns are fast and live-searchable. The item-form preparation now fetches a bounded, take-limited window via `getRelationshipOptions` instead of an unbounded `findMany({})` per relationship field, always unioning the current value's id(s) so its label renders even outside the window. + +`ComboboxField` (single) and `RelationshipManager` (many) gain debounced live search: typing narrows results against the label field via the `relationshipOptions` serverAction op, without any wiring changes required in host apps — `ItemForm`/`SingletonView` already pass `serverAction` and `listKey` through. + +```typescript +// No config changes needed — AdminUI's edit page picks this up automatically. +// A field's relationship dropdown now: +// 1. Renders a bounded initial window (default 50) with the current value's label always visible +// 2. Debounces typed input and searches server-side via context.serverAction({ action: 'relationshipOptions', ... }) +``` + +Components without a wired `serverAction` (e.g. custom usages of `ComboboxField`/`RelationshipManager`) fall back to client-side filtering over the initial window, unchanged from previous behavior. diff --git a/packages/ui/src/components/ItemFormClient.tsx b/packages/ui/src/components/ItemFormClient.tsx index 71f47266..121bf734 100644 --- a/packages/ui/src/components/ItemFormClient.tsx +++ b/packages/ui/src/components/ItemFormClient.tsx @@ -144,6 +144,8 @@ export function ItemFormClient({ relationshipItems={relationshipData[fieldName] || []} relationshipLoading={false} basePath={basePath} + listKey={listKey} + serverAction={serverAction} /> ))} diff --git a/packages/ui/src/components/fields/ComboboxField.tsx b/packages/ui/src/components/fields/ComboboxField.tsx index 82f7b5ee..c6dbbc1a 100644 --- a/packages/ui/src/components/fields/ComboboxField.tsx +++ b/packages/ui/src/components/fields/ComboboxField.tsx @@ -11,6 +11,8 @@ import { ComboboxEmpty, ComboboxItem, } from '../../primitives/combobox.js' +import { useRelationshipSearch } from '../../lib/useRelationshipSearch.js' +import type { ServerActionInput } from '../../server/types.js' export interface ComboboxFieldProps { name: string @@ -24,6 +26,12 @@ export interface ComboboxFieldProps { mode?: 'read' | 'edit' isLoading?: boolean placeholder?: string + /** Raw list key of the list being edited — required (with `serverAction`) to live-search. */ + listKey?: string + /** Generic server action used to resolve `relationshipOptions`. */ + serverAction?: (input: ServerActionInput) => Promise + /** Debounce delay (ms) before a typed query issues a server search. @default 300 */ + debounceMs?: number } export function ComboboxField({ @@ -38,28 +46,35 @@ export function ComboboxField({ mode = 'edit', isLoading = false, placeholder = 'Select...', + listKey, + serverAction, + debounceMs = 300, }: ComboboxFieldProps) { const [open, setOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') + const { searchQuery, setSearchQuery, searchResults, isSearching, resolveLabel } = + useRelationshipSearch({ + initialItems: items, + listKey, + fieldName: name, + serverAction, + selectedIds: value ? [value] : [], + debounceMs, + }) + + const selectedLabel = value + ? (resolveLabel(value) ?? items.find((item) => item.id === value)?.label) + : undefined // Read mode if (mode === 'read') { - const selectedItem = items.find((item) => item.id === value) return (
-

{selectedItem?.label || '-'}

+

{selectedLabel || '-'}

) } - // Filter items based on search query - const filteredItems = searchQuery - ? items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase())) - : items - - const selectedItem = items.find((item) => item.id === value) - return (
- - {isLoading ? 'Loading...' : selectedItem ? selectedItem.label : placeholder} + + {isLoading ? 'Loading...' : selectedLabel || placeholder} @@ -85,7 +100,9 @@ export function ComboboxField({ }} /> - {filteredItems.length === 0 ? ( + {isSearching ? ( + Searching... + ) : searchResults.length === 0 ? ( ) : ( <> @@ -103,7 +120,7 @@ export function ComboboxField({
)} - {filteredItems.map((item) => ( + {searchResults.map((item) => ( relationshipLoading?: boolean basePath?: string + /** Raw list key of the list being edited — required (with `serverAction`) for relationship fields to live-search. */ + listKey?: string + /** Generic server action used to resolve `relationshipOptions` for relationship fields. */ + serverAction?: (input: ServerActionInput) => Promise } /** @@ -34,6 +39,8 @@ function FieldRendererInner({ relationshipItems, relationshipLoading, basePath, + listKey, + serverAction, // eslint-disable-next-line @typescript-eslint/no-explicit-any }: FieldRendererProps & { Component: React.ComponentType }) { const label = (fieldConfig as Record).label || formatFieldName(fieldName) @@ -63,6 +70,8 @@ function FieldRendererInner({ relationshipItems, relationshipLoading, basePath, + listKey, + serverAction, ) // Pass through any UI options from fieldConfig.ui (excluding component and fieldType) @@ -84,6 +93,8 @@ function buildFallbackUIProps( relationshipItems: Array<{ id: string; label: string }> | undefined, relationshipLoading: boolean | undefined, basePath: string | undefined, + listKey: string | undefined, + serverAction: ((input: ServerActionInput) => Promise) | undefined, ): Record { const props: Record = {} @@ -100,6 +111,11 @@ function buildFallbackUIProps( props.items = relationshipItems props.isLoading = relationshipLoading props.basePath = basePath + // Live search needs the raw listKey of the list being edited (the + // `relationshipOptions` op resolves the related list itself from the + // field config) plus the generic server action to call it through. + props.listKey = listKey + props.serverAction = serverAction } return props diff --git a/packages/ui/src/components/fields/RelationshipField.tsx b/packages/ui/src/components/fields/RelationshipField.tsx index 6fb1938c..b8b26323 100644 --- a/packages/ui/src/components/fields/RelationshipField.tsx +++ b/packages/ui/src/components/fields/RelationshipField.tsx @@ -2,6 +2,7 @@ import { ComboboxField } from './ComboboxField.js' import { RelationshipManager } from './RelationshipManager.js' +import type { ServerActionInput } from '../../server/types.js' export interface RelationshipFieldProps { name: string @@ -17,6 +18,10 @@ export interface RelationshipFieldProps { many?: boolean relatedListKey?: string basePath?: string + /** Raw list key of the list being edited — required (with `serverAction`) to live-search. */ + listKey?: string + /** Generic server action used to resolve `relationshipOptions`. */ + serverAction?: (input: ServerActionInput) => Promise } export function RelationshipField({ @@ -33,6 +38,8 @@ export function RelationshipField({ many = false, relatedListKey, basePath, + listKey, + serverAction, }: RelationshipFieldProps) { // Delegate to specialized components based on cardinality if (many) { @@ -50,6 +57,8 @@ export function RelationshipField({ isLoading={isLoading} relatedListKey={relatedListKey} basePath={basePath} + listKey={listKey} + serverAction={serverAction} /> ) } @@ -66,6 +75,8 @@ export function RelationshipField({ required={required} mode={mode} isLoading={isLoading} + listKey={listKey} + serverAction={serverAction} /> ) } diff --git a/packages/ui/src/components/fields/RelationshipManager.tsx b/packages/ui/src/components/fields/RelationshipManager.tsx index 388b26d0..68a33f0f 100644 --- a/packages/ui/src/components/fields/RelationshipManager.tsx +++ b/packages/ui/src/components/fields/RelationshipManager.tsx @@ -21,6 +21,8 @@ import { ComboboxItem, } from '../../primitives/combobox.js' import { Button } from '../../primitives/button.js' +import { useRelationshipSearch } from '../../lib/useRelationshipSearch.js' +import type { ServerActionInput } from '../../server/types.js' export interface RelationshipManagerProps { name: string @@ -35,10 +37,16 @@ export interface RelationshipManagerProps { isLoading?: boolean relatedListKey?: string basePath?: string + /** Raw list key of the list being edited — required (with `serverAction`) to live-search. */ + listKey?: string + /** Generic server action used to resolve `relationshipOptions`. */ + serverAction?: (input: ServerActionInput) => Promise + /** Debounce delay (ms) before a typed query issues a server search. @default 300 */ + debounceMs?: number } export function RelationshipManager({ - name: _name, + name, value, onChange, label, @@ -50,13 +58,29 @@ export function RelationshipManager({ isLoading = false, relatedListKey, basePath = '/admin', + listKey, + serverAction, + debounceMs = 300, }: RelationshipManagerProps) { const [showConnectModal, setShowConnectModal] = useState(false) - const [searchQuery, setSearchQuery] = useState('') const selectedIds = Array.isArray(value) ? value : [] - const selectedItems = items.filter((item) => selectedIds.includes(item.id)) - const availableItems = items.filter((item) => !selectedIds.includes(item.id)) + + const { searchQuery, setSearchQuery, searchResults, isSearching, resolveLabel } = + useRelationshipSearch({ + initialItems: items, + listKey, + fieldName: name, + serverAction, + selectedIds, + debounceMs, + }) + + const selectedItems = selectedIds.map((id) => ({ + id, + label: resolveLabel(id) ?? items.find((item) => item.id === id)?.label ?? id, + })) + const availableItems = searchResults.filter((item) => !selectedIds.includes(item.id)) // Read mode if (mode === 'read') { @@ -70,11 +94,6 @@ export function RelationshipManager({ ) } - // Filter available items based on search - const filteredAvailableItems = searchQuery - ? availableItems.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase())) - : availableItems - const handleRemove = (itemId: string) => { onChange(selectedIds.filter((id) => id !== itemId)) } @@ -144,10 +163,7 @@ export function RelationshipManager({ {/* Action Buttons */}
- + {isLoading ? 'Loading...' : 'Connect Existing'} @@ -162,14 +178,12 @@ export function RelationshipManager({ }} /> - {filteredAvailableItems.length === 0 ? ( - - {availableItems.length === 0 - ? 'All items are already connected' - : 'No results found'} - + {isSearching ? ( + Searching... + ) : availableItems.length === 0 ? ( + No results found ) : ( - filteredAvailableItems.map((item) => ( + availableItems.map((item) => ( handleConnect(item.id)}> {item.label} diff --git a/packages/ui/src/lib/prepareItemForm.ts b/packages/ui/src/lib/prepareItemForm.ts index 64302c40..bc1a88a6 100644 --- a/packages/ui/src/lib/prepareItemForm.ts +++ b/packages/ui/src/lib/prepareItemForm.ts @@ -1,7 +1,23 @@ -import { type AccessContext, getDbKey, OpenSaasConfig } from '@opensaas/stack-core' +import { type AccessContext, getRelationshipOptions, OpenSaasConfig } from '@opensaas/stack-core' import type { ListConfig } from '@opensaas/stack-core' import { serializeFieldConfigs, type SerializableFieldConfig } from './serializeFieldConfig.js' +/** + * Extract the currently-selected id(s) from a hydrated relationship value so + * they can be unioned into the bounded options fetch (the item's current + * value must always resolve a label, even outside the window). + */ +function extractSelectedIds(value: unknown, many: boolean | undefined): string[] { + if (many) { + return Array.isArray(value) + ? value + .map((item) => (item as { id?: string })?.id) + .filter((id): id is string => typeof id === 'string') + : [] + } + return value && typeof value === 'object' && 'id' in value ? [(value as { id: string }).id] : [] +} + /** * Data prepared on the server for the client item form. * @@ -49,27 +65,28 @@ export async function prepareItemForm( listConfig: ListConfig, itemData: Record, ): Promise { - // Fetch relationship options for all relationship fields + // Fetch a bounded window of relationship options for all relationship + // fields via the relationship-options read primitive — this replaces an + // unbounded `findMany({})` per field with a scalar-only, take-limited + // fetch that always unions the item's currently-selected id(s) so their + // label renders even outside the window. const relationshipData: Record> = {} for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) { - const fieldConfigAny = fieldConfig as { type: string; ref?: string } + const fieldConfigAny = fieldConfig as { type: string; ref?: string; many?: boolean } if (fieldConfigAny.type === 'relationship') { const ref = fieldConfigAny.ref if (ref) { // Parse ref format: "ListName.fieldName" const relatedListName = ref.split('.')[0] - const relatedListConfig = config.lists[relatedListName] - if (relatedListConfig) { + if (config.lists[relatedListName]) { try { - const delegate = context.db[getDbKey(relatedListName)] - const relatedItems = delegate?.findMany ? await delegate.findMany({}) : [] - - // Use 'name' field as label if it exists, otherwise use 'id' - relationshipData[fieldName] = relatedItems.map((item: Record) => ({ - id: item.id as string, - label: ((item.name || item.title || item.id) as string) || '', - })) + relationshipData[fieldName] = await getRelationshipOptions( + context, + config, + relatedListName, + { selectedIds: extractSelectedIds(itemData[fieldName], fieldConfigAny.many) }, + ) } catch (error) { console.error(`Failed to fetch relationship items for ${fieldName}:`, error) relationshipData[fieldName] = [] diff --git a/packages/ui/src/lib/useRelationshipSearch.ts b/packages/ui/src/lib/useRelationshipSearch.ts new file mode 100644 index 00000000..b5a485bd --- /dev/null +++ b/packages/ui/src/lib/useRelationshipSearch.ts @@ -0,0 +1,143 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { ServerActionInput } from '../server/types.js' + +export interface RelationshipSearchOption { + id: string + label: string +} + +export interface UseRelationshipSearchOptions { + /** The server-rendered initial window — shown before any search query, and used as the client-side filter fallback when no server action is wired. */ + initialItems: RelationshipSearchOption[] + /** Raw list key of the list being edited (e.g. `'Post'`), not the related list. */ + listKey?: string + /** The relationship field's name on that list (e.g. `'author'`). */ + fieldName?: string + serverAction?: (input: ServerActionInput) => Promise + /** Currently-selected id(s), unioned server-side so their label always resolves. */ + selectedIds?: string[] + debounceMs?: number +} + +export interface UseRelationshipSearchResult { + searchQuery: string + setSearchQuery: (query: string) => void + /** Candidate options for the current query — the initial window when empty, the live search response otherwise. */ + searchResults: RelationshipSearchOption[] + isSearching: boolean + /** Resolves a label for any id ever seen (initial window or a past search response), so a value stays labelled once fetched. */ + resolveLabel: (id: string) => string | undefined +} + +function extractOptions(result: unknown): RelationshipSearchOption[] { + if ( + result && + typeof result === 'object' && + (result as { success?: unknown }).success === true && + Array.isArray((result as { data?: unknown }).data) + ) { + return (result as { data: RelationshipSearchOption[] }).data + } + return [] +} + +/** + * Debounced live search over the `relationshipOptions` serverAction op, + * seeded with the server-rendered initial window. + * + * Falls back to client-side filtering over `initialItems` when no + * `serverAction`/`listKey`/`fieldName` is supplied. + */ +export function useRelationshipSearch({ + initialItems, + listKey, + fieldName, + serverAction, + selectedIds = [], + debounceMs = 300, +}: UseRelationshipSearchOptions): UseRelationshipSearchResult { + const [searchQuery, setSearchQuery] = useState('') + // Only ever written from a resolved server search response — never derived + // from render inputs — so it's a legitimate piece of state, not a value + // that belongs in an effect-driven copy of props. + const [liveResults, setLiveResults] = useState(null) + const [isSearching, setIsSearching] = useState(false) + const [fetchedLabels, setFetchedLabels] = useState>(new Map()) + + const canSearchServer = Boolean(serverAction && listKey && fieldName) + const trimmedQuery = searchQuery.trim() + + // Props that change often (inline callbacks, freshly-derived arrays) are + // read from a ref inside the debounced callback so the search effect only + // re-runs when the query itself (or the search mode) changes. Updated in an + // effect (not during render) per the rules of hooks. + const latestRef = useRef({ serverAction, listKey, fieldName, selectedIds }) + useEffect(() => { + latestRef.current = { serverAction, listKey, fieldName, selectedIds } + }) + + // Derived directly from render inputs — no effect needed for the + // no-server / empty-query cases. + const searchResults = !canSearchServer + ? trimmedQuery + ? initialItems.filter((item) => item.label.toLowerCase().includes(trimmedQuery.toLowerCase())) + : initialItems + : trimmedQuery + ? (liveResults ?? []) + : initialItems + + // The debounced server search is the one genuine side effect — it + // synchronizes with an external system (the server action). + useEffect(() => { + if (!canSearchServer || !trimmedQuery) { + return + } + + let cancelled = false + const timer = setTimeout(() => { + setIsSearching(true) + const { + serverAction: action, + listKey: currentListKey, + fieldName: currentFieldName, + selectedIds: currentSelectedIds, + } = latestRef.current + void action!({ + listKey: currentListKey!, + action: 'relationshipOptions', + field: currentFieldName!, + search: trimmedQuery, + selectedIds: currentSelectedIds, + }).then((result) => { + if (cancelled) return + const options = extractOptions(result) + setLiveResults(options) + setIsSearching(false) + setFetchedLabels((prev) => { + if (options.length === 0) return prev + let changed = false + const next = new Map(prev) + for (const { id, label } of options) { + if (next.get(id) !== label) { + next.set(id, label) + changed = true + } + } + return changed ? next : prev + }) + }) + }, debounceMs) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [trimmedQuery, canSearchServer, debounceMs]) + + const resolveLabel = (id: string) => + fetchedLabels.get(id) ?? initialItems.find((item) => item.id === id)?.label + + return { searchQuery, setSearchQuery, searchResults, isSearching, resolveLabel } +} diff --git a/packages/ui/tests/components/ComboboxField.test.tsx b/packages/ui/tests/components/ComboboxField.test.tsx new file mode 100644 index 00000000..e01b719d --- /dev/null +++ b/packages/ui/tests/components/ComboboxField.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ComboboxField } from '../../src/components/fields/ComboboxField.js' + +const initialItems = [ + { id: 'u1', label: 'Ada Lovelace' }, + { id: 'u2', label: 'Alan Turing' }, +] + +describe('ComboboxField', () => { + describe('without a server action (client-side filter fallback)', () => { + it('renders the initial window and filters it locally while typing', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('button')) + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('Alan Turing')).toBeInTheDocument() + + await user.type(screen.getByPlaceholderText('Search...'), 'Ada') + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.queryByText('Alan Turing')).not.toBeInTheDocument() + }) + }) + + describe('with a server action (debounced live search)', () => { + it('shows the currently-selected value label on initial render without searching', () => { + const serverAction = vi.fn() + render( + , + ) + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(serverAction).not.toHaveBeenCalled() + }) + + it('debounces typed input and calls the relationshipOptions op with listKey/field/search', async () => { + const user = userEvent.setup() + const serverAction = vi.fn().mockResolvedValue({ + success: true, + data: [{ id: 'u3', label: 'Grace Hopper' }], + }) + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.type(screen.getByPlaceholderText('Search...'), 'Grace') + + await waitFor(() => { + expect(serverAction).toHaveBeenCalledWith({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + search: 'Grace', + selectedIds: [], + }) + }) + + await waitFor(() => { + expect(screen.getByText('Grace Hopper')).toBeInTheDocument() + }) + // The un-matching initial-window item is no longer shown — the list + // reflects the server's search result, not a client-side filter. + expect(screen.queryByText('Ada Lovelace')).not.toBeInTheDocument() + }) + + it('persists the selection found via live search on change', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const serverAction = vi.fn().mockResolvedValue({ + success: true, + data: [{ id: 'u3', label: 'Grace Hopper' }], + }) + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.type(screen.getByPlaceholderText('Search...'), 'Grace') + await waitFor(() => expect(screen.getByText('Grace Hopper')).toBeInTheDocument()) + + await user.click(screen.getByText('Grace Hopper')) + expect(onChange).toHaveBeenCalledWith('u3') + }) + }) +}) diff --git a/packages/ui/tests/components/RelationshipManager.test.tsx b/packages/ui/tests/components/RelationshipManager.test.tsx new file mode 100644 index 00000000..45285a6f --- /dev/null +++ b/packages/ui/tests/components/RelationshipManager.test.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { RelationshipManager } from '../../src/components/fields/RelationshipManager.js' + +const initialItems = [ + { id: 't1', label: 'engineering' }, + { id: 't2', label: 'design' }, +] + +describe('RelationshipManager', () => { + it('renders the currently-connected value label from the seeded window without searching', () => { + const serverAction = vi.fn() + render( + , + ) + + expect(screen.getByText('engineering')).toBeInTheDocument() + expect(serverAction).not.toHaveBeenCalled() + }) + + it('excludes already-connected items from the connect-existing candidates', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByText('Connect Existing')) + expect(screen.getByText('design')).toBeInTheDocument() + // "engineering" appears once, in the connected table — not in the candidate list. + expect(screen.getAllByText('engineering')).toHaveLength(1) + }) + + it('debounces typed input and calls the relationshipOptions op with listKey/field/search/selectedIds', async () => { + const user = userEvent.setup() + const serverAction = vi.fn().mockResolvedValue({ + success: true, + data: [{ id: 't9', label: 'security' }], + }) + + render( + , + ) + + await user.click(screen.getByText('Connect Existing')) + await user.type(screen.getByPlaceholderText('Search...'), 'sec') + + await waitFor(() => { + expect(serverAction).toHaveBeenCalledWith({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'tags', + search: 'sec', + selectedIds: ['t1'], + }) + }) + + await waitFor(() => { + expect(screen.getByText('security')).toBeInTheDocument() + }) + }) + + it('connecting an item found via live search persists it and keeps its label visible', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const serverAction = vi.fn().mockResolvedValue({ + success: true, + data: [{ id: 't9', label: 'security' }], + }) + + function Wrapper() { + const [value, setValue] = useState(['t1']) + return ( + { + setValue(next) + onChange(next) + }} + label="Tags" + items={initialItems} + listKey="Post" + serverAction={serverAction} + debounceMs={10} + /> + ) + } + + render() + + await user.click(screen.getByText('Connect Existing')) + await user.type(screen.getByPlaceholderText('Search...'), 'sec') + await waitFor(() => expect(screen.getByText('security')).toBeInTheDocument()) + + await user.click(screen.getByText('security')) + expect(onChange).toHaveBeenCalledWith(['t1', 't9']) + + // Still labelled after the connect-dropdown closes and the query resets. + expect(screen.getByText('security')).toBeInTheDocument() + expect(screen.getByText('engineering')).toBeInTheDocument() + }) +}) diff --git a/packages/ui/tests/lib/prepareItemForm.test.ts b/packages/ui/tests/lib/prepareItemForm.test.ts new file mode 100644 index 00000000..b09e4a38 --- /dev/null +++ b/packages/ui/tests/lib/prepareItemForm.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest' +import { prepareItemForm } from '../../src/lib/prepareItemForm.js' +import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core' + +interface DelegateStub { + findMany: (args?: unknown) => Promise>> + findFirst: (args?: unknown) => Promise | null> +} + +function makeContext(delegates: Record): AccessContext { + const context = { + db: delegates, + session: null, + storage: {}, + plugins: {}, + _isSudo: false, + _resolveOutputCounter: { depth: 0 }, + } + return context as unknown as AccessContext +} + +function makeConfig(): OpenSaasConfig { + return { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + Author: { + fields: { name: { type: 'text' } }, + access: { operation: { query: () => true } }, + }, + Tag: { + fields: { name: { type: 'text' } }, + access: { operation: { query: () => true } }, + }, + Post: { + fields: { + title: { type: 'text' }, + author: { type: 'relationship', ref: 'Author.posts' }, + tags: { type: 'relationship', ref: 'Tag.posts', many: true }, + }, + access: { operation: { query: () => true } }, + }, + }, + } as unknown as OpenSaasConfig +} + +describe('prepareItemForm', () => { + it('fetches relationship options via a bounded, take-limited query — never an unbounded findMany({})', async () => { + const authorFindMany = vi.fn(async () => [ + { id: 'a1', name: 'Ada Lovelace' }, + { id: 'a2', name: 'Alan Turing' }, + ]) + const tagFindMany = vi.fn(async () => [{ id: 't1', name: 'engineering' }]) + const context = makeContext({ + author: { findMany: authorFindMany, findFirst: vi.fn() }, + tag: { findMany: tagFindMany, findFirst: vi.fn() }, + }) + const config = makeConfig() + + const { relationshipData } = await prepareItemForm(context, config, config.lists.Post, {}) + + expect(relationshipData.author).toEqual([ + { id: 'a1', label: 'Ada Lovelace' }, + { id: 'a2', label: 'Alan Turing' }, + ]) + expect(relationshipData.tags).toEqual([{ id: 't1', label: 'engineering' }]) + + // Every findMany call must be take-bounded — no unbounded `{}` fetch. + for (const call of [...authorFindMany.mock.calls, ...tagFindMany.mock.calls]) { + const args = call[0] as Record | undefined + expect(args?.take).toBeGreaterThan(0) + } + }) + + it('unions the currently-selected single-relationship id even when outside the bounded window', async () => { + // The bounded window only returns a1; a9 (the item's current author) is + // outside it and must be unioned in via a second, id-scoped query. + const authorFindMany = vi + .fn<(args?: unknown) => Promise>>>() + .mockResolvedValueOnce([{ id: 'a1', name: 'Ada Lovelace' }]) + .mockResolvedValueOnce([{ id: 'a9', name: 'Currently Selected' }]) + const context = makeContext({ + author: { findMany: authorFindMany, findFirst: vi.fn() }, + tag: { findMany: vi.fn(async () => []), findFirst: vi.fn() }, + }) + const config = makeConfig() + + const itemData = { id: 'p1', title: 'Post', author: { id: 'a9', name: 'Currently Selected' } } + const { relationshipData } = await prepareItemForm(context, config, config.lists.Post, itemData) + + expect(relationshipData.author).toEqual( + expect.arrayContaining([{ id: 'a9', label: 'Currently Selected' }]), + ) + const selectedIdCall = authorFindMany.mock.calls[1][0] as Record + expect(selectedIdCall.where).toEqual({ id: { in: ['a9'] } }) + }) + + it('unions every currently-selected id for a many relationship', async () => { + const tagFindMany = vi + .fn<(args?: unknown) => Promise>>>() + .mockResolvedValueOnce([{ id: 't1', name: 'engineering' }]) + .mockResolvedValueOnce([{ id: 't9', name: 'design' }]) + const context = makeContext({ + author: { findMany: vi.fn(async () => []), findFirst: vi.fn() }, + tag: { findMany: tagFindMany, findFirst: vi.fn() }, + }) + const config = makeConfig() + + const itemData = { + id: 'p1', + title: 'Post', + tags: [ + { id: 't1', name: 'engineering' }, + { id: 't9', name: 'design' }, + ], + } + const { relationshipData } = await prepareItemForm(context, config, config.lists.Post, itemData) + + expect(relationshipData.tags).toEqual( + expect.arrayContaining([ + { id: 't1', label: 'engineering' }, + { id: 't9', label: 'design' }, + ]), + ) + const selectedIdCall = tagFindMany.mock.calls[1][0] as Record + expect(selectedIdCall.where).toEqual({ id: { in: ['t9'] } }) + }) + + it('passes no selectedIds when the relationship is empty (create mode)', async () => { + const authorFindMany = vi.fn(async () => []) + const context = makeContext({ + author: { findMany: authorFindMany, findFirst: vi.fn() }, + tag: { findMany: vi.fn(async () => []), findFirst: vi.fn() }, + }) + const config = makeConfig() + + await prepareItemForm(context, config, config.lists.Post, {}) + + // Only the primary bounded query runs — no second, id-scoped query. + expect(authorFindMany).toHaveBeenCalledTimes(1) + }) +}) From 888d169eb9aef62577fb3e4f0792901f19df3b89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 08:40:26 +0000 Subject: [PATCH 2/2] fix(ui): handle rejected relationshipOptions search in useRelationshipSearch A failed serverAction call left isSearching stuck true forever and produced an unhandled promise rejection. Catch the error, log it, fall back to an empty result set, and always clear isSearching. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LVrp6NFf7v2WrxCT7d8i3Z --- packages/ui/src/lib/useRelationshipSearch.ts | 40 +++++++++++-------- .../tests/components/ComboboxField.test.tsx | 27 +++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/lib/useRelationshipSearch.ts b/packages/ui/src/lib/useRelationshipSearch.ts index b5a485bd..1923c988 100644 --- a/packages/ui/src/lib/useRelationshipSearch.ts +++ b/packages/ui/src/lib/useRelationshipSearch.ts @@ -110,24 +110,32 @@ export function useRelationshipSearch({ field: currentFieldName!, search: trimmedQuery, selectedIds: currentSelectedIds, - }).then((result) => { - if (cancelled) return - const options = extractOptions(result) - setLiveResults(options) - setIsSearching(false) - setFetchedLabels((prev) => { - if (options.length === 0) return prev - let changed = false - const next = new Map(prev) - for (const { id, label } of options) { - if (next.get(id) !== label) { - next.set(id, label) - changed = true + }) + .then((result) => { + if (cancelled) return + const options = extractOptions(result) + setLiveResults(options) + setFetchedLabels((prev) => { + if (options.length === 0) return prev + let changed = false + const next = new Map(prev) + for (const { id, label } of options) { + if (next.get(id) !== label) { + next.set(id, label) + changed = true + } } - } - return changed ? next : prev + return changed ? next : prev + }) + }) + .catch((error: unknown) => { + if (cancelled) return + console.error('Failed to search relationship options:', error) + setLiveResults([]) + }) + .finally(() => { + if (!cancelled) setIsSearching(false) }) - }) }, debounceMs) return () => { diff --git a/packages/ui/tests/components/ComboboxField.test.tsx b/packages/ui/tests/components/ComboboxField.test.tsx index e01b719d..df135dd2 100644 --- a/packages/ui/tests/components/ComboboxField.test.tsx +++ b/packages/ui/tests/components/ComboboxField.test.tsx @@ -120,5 +120,32 @@ describe('ComboboxField', () => { await user.click(screen.getByText('Grace Hopper')) expect(onChange).toHaveBeenCalledWith('u3') }) + + it('recovers from a rejected search instead of getting stuck on "Searching..."', async () => { + const user = userEvent.setup() + const serverAction = vi.fn().mockRejectedValue(new Error('network error')) + + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.type(screen.getByPlaceholderText('Search...'), 'Grace') + + await waitFor(() => expect(serverAction).toHaveBeenCalled()) + await waitFor(() => { + expect(screen.queryByText('Searching...')).not.toBeInTheDocument() + }) + expect(screen.getByText('No results found.')).toBeInTheDocument() + }) }) })