Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/tame-owls-wander.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/ui/src/components/ItemFormClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ export function ItemFormClient({
relationshipItems={relationshipData[fieldName] || []}
relationshipLoading={false}
basePath={basePath}
listKey={listKey}
serverAction={serverAction}
/>
))}
</div>
Expand Down
45 changes: 31 additions & 14 deletions packages/ui/src/components/fields/ComboboxField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<unknown>
/** Debounce delay (ms) before a typed query issues a server search. @default 300 */
debounceMs?: number
}

export function ComboboxField({
Expand All @@ -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 (
<div className="space-y-1">
<label className="text-sm font-medium text-muted-foreground">{label}</label>
<p className="text-sm">{selectedItem?.label || '-'}</p>
<p className="text-sm">{selectedLabel || '-'}</p>
</div>
)
}

// 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 (
<div className="space-y-2">
<label htmlFor={name} className="text-sm font-medium">
Expand All @@ -68,8 +83,8 @@ export function ComboboxField({
</label>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger disabled={disabled || isLoading}>
<span className={!selectedItem ? 'text-muted-foreground' : ''}>
{isLoading ? 'Loading...' : selectedItem ? selectedItem.label : placeholder}
<span className={!selectedLabel ? 'text-muted-foreground' : ''}>
{isLoading ? 'Loading...' : selectedLabel || placeholder}
</span>
</ComboboxTrigger>
<ComboboxContent>
Expand All @@ -85,7 +100,9 @@ export function ComboboxField({
}}
/>
<ComboboxList>
{filteredItems.length === 0 ? (
{isSearching ? (
<ComboboxEmpty>Searching...</ComboboxEmpty>
) : searchResults.length === 0 ? (
<ComboboxEmpty />
) : (
<>
Expand All @@ -103,7 +120,7 @@ export function ComboboxField({
<div className="-mx-1 my-1 h-px bg-border" />
</>
)}
{filteredItems.map((item) => (
{searchResults.map((item) => (
<ComboboxItem
key={item.id}
selected={item.id === value}
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/components/fields/FieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getFieldComponent } from './registry.js'
import { formatFieldName } from '../../lib/utils.js'
import { getUrlKey } from '@opensaas/stack-core'
import type { SerializableFieldConfig } from '../../lib/serializeFieldConfig.js'
import type { ServerActionInput } from '../../server/types.js'

export interface FieldRendererProps {
fieldName: string
Expand All @@ -17,6 +18,10 @@ export interface FieldRendererProps {
relationshipItems?: Array<{ id: string; label: string }>
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<unknown>
}

/**
Expand All @@ -34,6 +39,8 @@ function FieldRendererInner({
relationshipItems,
relationshipLoading,
basePath,
listKey,
serverAction,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}: FieldRendererProps & { Component: React.ComponentType<any> }) {
const label = (fieldConfig as Record<string, unknown>).label || formatFieldName(fieldName)
Expand Down Expand Up @@ -63,6 +70,8 @@ function FieldRendererInner({
relationshipItems,
relationshipLoading,
basePath,
listKey,
serverAction,
)

// Pass through any UI options from fieldConfig.ui (excluding component and fieldType)
Expand All @@ -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<unknown>) | undefined,
): Record<string, unknown> {
const props: Record<string, unknown> = {}

Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/components/fields/RelationshipField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<unknown>
}

export function RelationshipField({
Expand All @@ -33,6 +38,8 @@ export function RelationshipField({
many = false,
relatedListKey,
basePath,
listKey,
serverAction,
}: RelationshipFieldProps) {
// Delegate to specialized components based on cardinality
if (many) {
Expand All @@ -50,6 +57,8 @@ export function RelationshipField({
isLoading={isLoading}
relatedListKey={relatedListKey}
basePath={basePath}
listKey={listKey}
serverAction={serverAction}
/>
)
}
Expand All @@ -66,6 +75,8 @@ export function RelationshipField({
required={required}
mode={mode}
isLoading={isLoading}
listKey={listKey}
serverAction={serverAction}
/>
)
}
54 changes: 34 additions & 20 deletions packages/ui/src/components/fields/RelationshipManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<unknown>
/** Debounce delay (ms) before a typed query issues a server search. @default 300 */
debounceMs?: number
}

export function RelationshipManager({
name: _name,
name,
value,
onChange,
label,
Expand All @@ -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') {
Expand All @@ -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))
}
Expand Down Expand Up @@ -144,10 +163,7 @@ export function RelationshipManager({
{/* Action Buttons */}
<div className="flex gap-2">
<Combobox open={showConnectModal} onOpenChange={setShowConnectModal}>
<ComboboxTrigger
disabled={disabled || isLoading || availableItems.length === 0}
className="h-9 px-3"
>
<ComboboxTrigger disabled={disabled || isLoading} className="h-9 px-3">
<span>{isLoading ? 'Loading...' : 'Connect Existing'}</span>
</ComboboxTrigger>
<ComboboxContent>
Expand All @@ -162,14 +178,12 @@ export function RelationshipManager({
}}
/>
<ComboboxList>
{filteredAvailableItems.length === 0 ? (
<ComboboxEmpty>
{availableItems.length === 0
? 'All items are already connected'
: 'No results found'}
</ComboboxEmpty>
{isSearching ? (
<ComboboxEmpty>Searching...</ComboboxEmpty>
) : availableItems.length === 0 ? (
<ComboboxEmpty>No results found</ComboboxEmpty>
) : (
filteredAvailableItems.map((item) => (
availableItems.map((item) => (
<ComboboxItem key={item.id} onClick={() => handleConnect(item.id)}>
{item.label}
</ComboboxItem>
Expand Down
43 changes: 30 additions & 13 deletions packages/ui/src/lib/prepareItemForm.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -49,27 +65,28 @@ export async function prepareItemForm(
listConfig: ListConfig<any>,
itemData: Record<string, unknown>,
): Promise<PreparedItemForm> {
// 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<string, Array<{ id: string; label: string }>> = {}
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<string, unknown>) => ({
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] = []
Expand Down
Loading
Loading