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..1923c988 --- /dev/null +++ b/packages/ui/src/lib/useRelationshipSearch.ts @@ -0,0 +1,151 @@ +'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) + 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 + }) + }) + .catch((error: unknown) => { + if (cancelled) return + console.error('Failed to search relationship options:', error) + setLiveResults([]) + }) + .finally(() => { + if (!cancelled) setIsSearching(false) + }) + }, 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..df135dd2 --- /dev/null +++ b/packages/ui/tests/components/ComboboxField.test.tsx @@ -0,0 +1,151 @@ +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') + }) + + 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() + }) + }) +}) 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) + }) +})