@open-mercato/ui 0.9.1-develop.7474.1.51db10479d → 0.9.1-develop.7475.1.28ca93ed97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { ComboboxInput } from './ComboboxInput'
6
+ import { TagsInput } from './TagsInput'
7
+ import {
8
+ resolveLookupFailureReason,
9
+ type LookupLoadFailureReason,
10
+ type LookupOption,
11
+ type LookupSource,
12
+ } from './lookupSources'
13
+
14
+ function LookupFailureMessage({ failure }: { failure: LookupLoadFailureReason | null }) {
15
+ const t = useT()
16
+ if (!failure) return null
17
+ return (
18
+ <p className="mt-1 text-xs text-status-error-text" role="alert" data-testid="lookup-picker-failure">
19
+ {failure === 'forbidden'
20
+ ? t('ui.inputs.lookupPicker.forbidden', 'You do not have permission to load these options.')
21
+ : t('ui.inputs.lookupPicker.loadFailed', 'Could not load options. Try again.')}
22
+ </p>
23
+ )
24
+ }
25
+
26
+ function useLookupFailure() {
27
+ const [failure, setFailure] = React.useState<LookupLoadFailureReason | null>(null)
28
+ const guard = React.useCallback(async <TValue,>(run: () => Promise<TValue>, fallback: TValue): Promise<TValue> => {
29
+ try {
30
+ const value = await run()
31
+ setFailure(null)
32
+ return value
33
+ } catch (error) {
34
+ setFailure(resolveLookupFailureReason(error))
35
+ return fallback
36
+ }
37
+ }, [])
38
+ return { failure, setFailure, guard }
39
+ }
40
+
41
+ function useLookupLabels(source: LookupSource, ids: readonly string[]) {
42
+ const [options, setOptions] = React.useState<Record<string, LookupOption>>({})
43
+ const { failure, setFailure, guard } = useLookupFailure()
44
+ const requested = React.useRef(new Set<string>())
45
+
46
+ const register = React.useCallback((next: readonly LookupOption[]) => {
47
+ if (next.length === 0) return
48
+ setOptions((previous) => {
49
+ const merged = { ...previous }
50
+ for (const option of next) merged[option.value] = option
51
+ return merged
52
+ })
53
+ }, [])
54
+
55
+ const idsKey = ids.join(',')
56
+ React.useEffect(() => {
57
+ const missing = idsKey
58
+ .split(',')
59
+ .filter((id) => id.length > 0 && !requested.current.has(id))
60
+ if (missing.length === 0) return
61
+ for (const id of missing) requested.current.add(id)
62
+ let cancelled = false
63
+ let settled = false
64
+ const release = () => {
65
+ for (const id of missing) requested.current.delete(id)
66
+ }
67
+ void source.resolve(missing).then(
68
+ (resolved) => {
69
+ settled = true
70
+ if (cancelled) return
71
+ register(resolved)
72
+ setFailure(null)
73
+ },
74
+ (error: unknown) => {
75
+ settled = true
76
+ if (cancelled) return
77
+ release()
78
+ setFailure(resolveLookupFailureReason(error))
79
+ },
80
+ )
81
+ return () => {
82
+ cancelled = true
83
+ if (!settled) release()
84
+ }
85
+ }, [idsKey, register, setFailure, source])
86
+
87
+ const search = React.useCallback(
88
+ (query?: string) =>
89
+ guard(async () => {
90
+ const found = await source.search(query)
91
+ register(found)
92
+ return found
93
+ }, []),
94
+ [guard, register, source],
95
+ )
96
+
97
+ return { options, search, failure }
98
+ }
99
+
100
+ type LookupMultiPickerProps = {
101
+ source: LookupSource
102
+ value: string[]
103
+ onChange: (next: string[]) => void
104
+ placeholder?: string
105
+ disabled?: boolean
106
+ }
107
+
108
+ export function LookupMultiPicker({ source, value, onChange, placeholder, disabled }: LookupMultiPickerProps) {
109
+ const { options, search, failure } = useLookupLabels(source, value)
110
+ const suggestions = React.useMemo(() => Object.values(options), [options])
111
+ return (
112
+ <div>
113
+ <TagsInput
114
+ value={value}
115
+ onChange={onChange}
116
+ suggestions={suggestions}
117
+ loadSuggestions={search}
118
+ allowCustomValues={false}
119
+ closeSuggestionsOnSelect
120
+ resolveLabel={(id) => options[id]?.label ?? id}
121
+ resolveDescription={(id) => options[id]?.description ?? null}
122
+ placeholder={placeholder}
123
+ disabled={disabled}
124
+ />
125
+ <LookupFailureMessage failure={failure} />
126
+ </div>
127
+ )
128
+ }
129
+
130
+ type LookupSinglePickerProps = {
131
+ source: LookupSource
132
+ value: string
133
+ onChange: (next: string) => void
134
+ placeholder?: string
135
+ disabled?: boolean
136
+ clearable?: boolean
137
+ }
138
+
139
+ export function LookupSinglePicker({
140
+ source,
141
+ value,
142
+ onChange,
143
+ placeholder,
144
+ disabled,
145
+ clearable,
146
+ }: LookupSinglePickerProps) {
147
+ const { failure, guard } = useLookupFailure()
148
+ const resolveLabel = React.useCallback(
149
+ (id: string) =>
150
+ guard(async () => {
151
+ const [option] = await source.resolve([id])
152
+ return option?.label ?? id
153
+ }, id),
154
+ [guard, source],
155
+ )
156
+ const search = React.useCallback((query?: string) => guard(() => source.search(query), []), [guard, source])
157
+ return (
158
+ <div>
159
+ <ComboboxInput
160
+ value={value}
161
+ onChange={onChange}
162
+ allowCustomValues={false}
163
+ clearable={clearable}
164
+ placeholder={placeholder}
165
+ disabled={disabled}
166
+ loadSuggestions={search}
167
+ resolveLabel={resolveLabel}
168
+ />
169
+ <LookupFailureMessage failure={failure} />
170
+ </div>
171
+ )
172
+ }
173
+
174
+ export function useLookupLabelMap(source: LookupSource, ids: readonly string[]): Record<string, string> {
175
+ const { options } = useLookupLabels(source, ids)
176
+ return React.useMemo(() => {
177
+ const labels: Record<string, string> = {}
178
+ for (const [id, option] of Object.entries(options)) labels[id] = option.label
179
+ return labels
180
+ }, [options])
181
+ }
@@ -25,6 +25,7 @@ export type TagsInputProps = {
25
25
  allowCustomValues?: boolean
26
26
  showSuggestionsOnFocus?: boolean
27
27
  suppressInitialSuggestionsOnFocus?: boolean
28
+ closeSuggestionsOnSelect?: boolean
28
29
  }
29
30
 
30
31
  function normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {
@@ -61,6 +62,7 @@ export function TagsInput({
61
62
  allowCustomValues = true,
62
63
  showSuggestionsOnFocus = true,
63
64
  suppressInitialSuggestionsOnFocus = false,
65
+ closeSuggestionsOnSelect = false,
64
66
  }: TagsInputProps) {
65
67
  const t = useT()
66
68
  const [input, setInput] = React.useState('')
@@ -275,6 +277,7 @@ export function TagsInput({
275
277
  event.preventDefault()
276
278
  addTag(input)
277
279
  setInput('')
280
+ if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)
278
281
  } else if (event.key === 'Backspace' && input === '' && value.length > 0) {
279
282
  removeTag(value[value.length - 1])
280
283
  }
@@ -310,6 +313,7 @@ export function TagsInput({
310
313
  suppressBlurCommitRef.current = false
311
314
  addValue(option.value)
312
315
  setInput('')
316
+ if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)
313
317
  }}
314
318
  >
315
319
  <span>{option.label}</span>
@@ -0,0 +1,120 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { fireEvent, screen } from '@testing-library/react'
4
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
5
+ import { LookupMultiPicker, LookupSinglePicker } from '../LookupPickers'
6
+ import { LookupLoadError, type LookupSource } from '../lookupSources'
7
+
8
+ const options = [
9
+ { value: 'cat-1', label: 'Category One' },
10
+ { value: 'cat-2', label: 'Category Two' },
11
+ ]
12
+
13
+ const source: LookupSource = {
14
+ id: 'test',
15
+ search: jest.fn().mockResolvedValue(options),
16
+ resolve: jest.fn().mockResolvedValue([]),
17
+ }
18
+
19
+ function Harness() {
20
+ const [value, setValue] = React.useState<string[]>([])
21
+ return <LookupMultiPicker source={source} value={value} onChange={setValue} />
22
+ }
23
+
24
+ describe('LookupMultiPicker', () => {
25
+ it('closes the suggestion list after a pick', async () => {
26
+ renderWithProviders(<Harness />)
27
+ fireEvent.focus(screen.getByRole('textbox'))
28
+ fireEvent.click(await screen.findByRole('button', { name: /Category One/ }))
29
+
30
+ expect(screen.queryByRole('button', { name: /Category Two/ })).not.toBeInTheDocument()
31
+ })
32
+ })
33
+
34
+ describe('LookupMultiPicker label resolution', () => {
35
+ it('resolves saved ids to labels under StrictMode, where the first lookup is cancelled', async () => {
36
+ const resolve = jest.fn(async (ids: readonly string[]) => options.filter((option) => ids.includes(option.value)))
37
+ const strictSource: LookupSource = { id: 'strict', search: jest.fn().mockResolvedValue([]), resolve }
38
+ renderWithProviders(
39
+ <React.StrictMode>
40
+ <LookupMultiPicker source={strictSource} value={['cat-2']} onChange={() => undefined} />
41
+ </React.StrictMode>,
42
+ )
43
+
44
+ expect(await screen.findByText('Category Two')).toBeInTheDocument()
45
+ })
46
+
47
+ it('retries ids whose lookup failed', async () => {
48
+ const resolve = jest
49
+ .fn()
50
+ .mockRejectedValueOnce(new Error('[internal] lookup failed'))
51
+ .mockImplementation(async (ids: readonly string[]) => options.filter((option) => ids.includes(option.value)))
52
+ const failingSource: LookupSource = { id: 'failing', search: jest.fn().mockResolvedValue([]), resolve }
53
+ const { rerender } = renderWithProviders(
54
+ <LookupMultiPicker source={failingSource} value={['cat-1']} onChange={() => undefined} />,
55
+ )
56
+ await screen.findByText('cat-1')
57
+ await Promise.resolve()
58
+ rerender(<LookupMultiPicker source={failingSource} value={['cat-1', 'cat-2']} onChange={() => undefined} />)
59
+
60
+ expect(await screen.findByText('Category One')).toBeInTheDocument()
61
+ expect(screen.getByText('Category Two')).toBeInTheDocument()
62
+ })
63
+ })
64
+
65
+ describe('LookupPickers load failures', () => {
66
+ it('shows an inline message when the options cannot be loaded', async () => {
67
+ const failingSource: LookupSource = {
68
+ id: 'offline',
69
+ search: jest.fn().mockRejectedValue(new LookupLoadError('failed', '/api/example')),
70
+ resolve: jest.fn().mockResolvedValue([]),
71
+ }
72
+ renderWithProviders(<LookupMultiPicker source={failingSource} value={[]} onChange={() => undefined} />)
73
+ fireEvent.focus(screen.getByRole('textbox'))
74
+
75
+ expect(await screen.findByTestId('lookup-picker-failure')).toHaveTextContent('Could not load options. Try again.')
76
+ })
77
+
78
+ it('tells the user when access to the options is forbidden', async () => {
79
+ const forbiddenSource: LookupSource = {
80
+ id: 'forbidden',
81
+ search: jest.fn().mockResolvedValue([]),
82
+ resolve: jest.fn().mockRejectedValue(new LookupLoadError('forbidden', '/api/example')),
83
+ }
84
+ renderWithProviders(<LookupMultiPicker source={forbiddenSource} value={['cat-1']} onChange={() => undefined} />)
85
+
86
+ expect(await screen.findByTestId('lookup-picker-failure')).toHaveTextContent(
87
+ 'You do not have permission to load these options.',
88
+ )
89
+ })
90
+
91
+ it('clears the message once a later load succeeds', async () => {
92
+ const search = jest
93
+ .fn()
94
+ .mockRejectedValueOnce(new LookupLoadError('failed', '/api/example'))
95
+ .mockResolvedValue(options)
96
+ const flakySource: LookupSource = { id: 'flaky', search, resolve: jest.fn().mockResolvedValue([]) }
97
+ renderWithProviders(<LookupMultiPicker source={flakySource} value={[]} onChange={() => undefined} />)
98
+ const input = screen.getByRole('textbox')
99
+ fireEvent.focus(input)
100
+ await screen.findByTestId('lookup-picker-failure')
101
+
102
+ fireEvent.change(input, { target: { value: 'Cat' } })
103
+
104
+ expect(await screen.findByRole('button', { name: /Category One/ })).toBeInTheDocument()
105
+ expect(screen.queryByTestId('lookup-picker-failure')).not.toBeInTheDocument()
106
+ })
107
+
108
+ it('keeps the raw id and shows the failure when a single picker cannot resolve its label', async () => {
109
+ const failingSource: LookupSource = {
110
+ id: 'single',
111
+ search: jest.fn().mockResolvedValue([]),
112
+ resolve: jest.fn().mockRejectedValue(new LookupLoadError('forbidden', '/api/example')),
113
+ }
114
+ renderWithProviders(<LookupSinglePicker source={failingSource} value="cat-1" onChange={() => undefined} />)
115
+
116
+ expect(await screen.findByTestId('lookup-picker-failure')).toHaveTextContent(
117
+ 'You do not have permission to load these options.',
118
+ )
119
+ })
120
+ })
@@ -116,6 +116,47 @@ describe('TagsInput', () => {
116
116
  jest.useRealTimers()
117
117
  })
118
118
 
119
+ describe('closeSuggestionsOnSelect', () => {
120
+ const pickSuggestions = [
121
+ { value: 'cat-1', label: 'Category One' },
122
+ { value: 'cat-2', label: 'Category Two' },
123
+ ]
124
+
125
+ function PickHarness({ closeSuggestionsOnSelect }: { closeSuggestionsOnSelect?: boolean }) {
126
+ const [value, setValue] = React.useState<string[]>([])
127
+ return (
128
+ <TagsInput
129
+ value={value}
130
+ onChange={setValue}
131
+ suggestions={pickSuggestions}
132
+ closeSuggestionsOnSelect={closeSuggestionsOnSelect}
133
+ />
134
+ )
135
+ }
136
+
137
+ it('keeps the remaining suggestions open after a pick by default', () => {
138
+ renderWithProviders(<PickHarness />)
139
+ const input = screen.getByRole('textbox')
140
+ fireEvent.focus(input)
141
+ fireEvent.click(screen.getByRole('button', { name: /Category One/ }))
142
+
143
+ expect(screen.getByRole('button', { name: /Category Two/ })).toBeInTheDocument()
144
+ })
145
+
146
+ it('dismisses the suggestions after a pick when opted in and reopens them on the next interaction', () => {
147
+ renderWithProviders(<PickHarness closeSuggestionsOnSelect />)
148
+ const input = screen.getByRole('textbox')
149
+ fireEvent.focus(input)
150
+ fireEvent.click(screen.getByRole('button', { name: /Category One/ }))
151
+
152
+ expect(screen.queryByRole('button', { name: /Category Two/ })).not.toBeInTheDocument()
153
+
154
+ fireEvent.mouseDown(input)
155
+
156
+ expect(screen.getByRole('button', { name: /Category Two/ })).toBeInTheDocument()
157
+ })
158
+ })
159
+
119
160
  describe('Escape on the suggestions list', () => {
120
161
  it('closes only the suggestions and keeps Escape away from the host', () => {
121
162
  const onHostKeyDown = jest.fn()
@@ -0,0 +1,87 @@
1
+ import { readApiResultOrThrow } from '../../utils/apiCall'
2
+ import {
3
+ LookupLoadError,
4
+ createIdsLookupSource,
5
+ lookupLabelWithCode,
6
+ pickLookupString,
7
+ resolveLookupFailureReason,
8
+ } from '../lookupSources'
9
+
10
+ jest.mock('../../utils/apiCall', () => ({
11
+ readApiResultOrThrow: jest.fn(),
12
+ }))
13
+
14
+ const readMock = readApiResultOrThrow as jest.Mock
15
+
16
+ const source = createIdsLookupSource(
17
+ 'widgets',
18
+ '/api/example/widgets',
19
+ (item) => {
20
+ const value = pickLookupString(item, 'id')
21
+ return value ? { value, label: pickLookupString(item, 'name') || value } : null
22
+ },
23
+ { view: 'manage' },
24
+ )
25
+
26
+ function httpError(status: number): Error & { status: number } {
27
+ return Object.assign(new Error('[internal] request failed'), { status })
28
+ }
29
+
30
+ describe('createIdsLookupSource', () => {
31
+ beforeEach(() => readMock.mockReset())
32
+
33
+ it('searches with extra params and drops items without an id', async () => {
34
+ readMock.mockResolvedValue({ items: [{ id: 'w-1', name: 'Widget' }, { name: 'no id' }] })
35
+ expect(await source.search(' wid ')).toEqual([{ value: 'w-1', label: 'Widget' }])
36
+ const url = new URL(readMock.mock.calls[0][0] as string, 'http://localhost')
37
+ expect(url.pathname).toBe('/api/example/widgets')
38
+ expect(url.searchParams.get('view')).toBe('manage')
39
+ expect(url.searchParams.get('search')).toBe('wid')
40
+ })
41
+
42
+ it('resolves selected ids without issuing a request for an empty list', async () => {
43
+ expect(await source.resolve([])).toEqual([])
44
+ expect(readMock).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('resolves ids in chunks of at most 100 through the ids filter', async () => {
48
+ const ids = Array.from({ length: 250 }, (_, index) => `id-${index}`)
49
+ readMock.mockImplementation(async (path: string) => {
50
+ const requested = new URL(path, 'http://localhost').searchParams.get('ids')?.split(',') ?? []
51
+ return { items: requested.map((id) => ({ id, name: id })) }
52
+ })
53
+ const options = await source.resolve([...ids, 'id-0'])
54
+ expect(options.map((option) => option.value)).toEqual(ids)
55
+ expect(readMock).toHaveBeenCalledTimes(3)
56
+ for (const [path] of readMock.mock.calls) {
57
+ const params = new URL(path as string, 'http://localhost').searchParams
58
+ expect(params.get('ids')?.split(',').length).toBeLessThanOrEqual(100)
59
+ expect(Number(params.get('pageSize'))).toBeLessThanOrEqual(100)
60
+ }
61
+ })
62
+
63
+ it('rejects with a forbidden failure when the endpoint denies access', async () => {
64
+ readMock.mockRejectedValue(httpError(403))
65
+ const error = await source.search('x').catch((err: unknown) => err)
66
+ expect(error).toBeInstanceOf(LookupLoadError)
67
+ expect(resolveLookupFailureReason(error)).toBe('forbidden')
68
+ })
69
+
70
+ it('rejects with a generic failure when the endpoint is unreachable', async () => {
71
+ readMock.mockRejectedValue(new Error('offline'))
72
+ const error = await source.resolve(['w-1']).catch((err: unknown) => err)
73
+ expect(resolveLookupFailureReason(error)).toBe('failed')
74
+ })
75
+ })
76
+
77
+ describe('lookup helpers', () => {
78
+ it('treats any non-lookup error as a generic failure', () => {
79
+ expect(resolveLookupFailureReason(new Error('boom'))).toBe('failed')
80
+ expect(resolveLookupFailureReason(undefined)).toBe('failed')
81
+ })
82
+
83
+ it('appends the code only when it differs from the label', () => {
84
+ expect(lookupLabelWithCode('Retail', 'retail')).toBe('Retail (retail)')
85
+ expect(lookupLabelWithCode('Retail', '')).toBe('Retail')
86
+ })
87
+ })
@@ -10,3 +10,5 @@ export * from './TimePicker'
10
10
  export * from './DateTimePicker'
11
11
  export * from './DatePicker'
12
12
  export * from './DurationInput'
13
+ export * from './lookupSources'
14
+ export * from './LookupPickers'
@@ -0,0 +1,114 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+ import { readApiResultOrThrow } from '../utils/apiCall'
3
+
4
+ const logger = createLogger('ui').child({ component: 'lookup-sources' })
5
+
6
+ export type LookupOption = { value: string; label: string; description?: string | null }
7
+
8
+ export type LookupSource = {
9
+ id: string
10
+ search: (query?: string) => Promise<LookupOption[]>
11
+ resolve: (ids: readonly string[]) => Promise<LookupOption[]>
12
+ }
13
+
14
+ export type LookupRemoteItem = Record<string, unknown>
15
+
16
+ export type LookupLoadFailureReason = 'forbidden' | 'failed'
17
+
18
+ export class LookupLoadError extends Error {
19
+ readonly reason: LookupLoadFailureReason
20
+
21
+ constructor(reason: LookupLoadFailureReason, path: string) {
22
+ super(`[internal] lookup load failed (${reason}) for ${path}`)
23
+ this.name = 'LookupLoadError'
24
+ this.reason = reason
25
+ }
26
+ }
27
+
28
+ export function resolveLookupFailureReason(error: unknown): LookupLoadFailureReason {
29
+ if (!error || typeof error !== 'object') return 'failed'
30
+ const candidate = error as { name?: unknown; reason?: unknown }
31
+ return candidate.name === 'LookupLoadError' && candidate.reason === 'forbidden' ? 'forbidden' : 'failed'
32
+ }
33
+
34
+ function readErrorStatus(error: unknown): number | null {
35
+ if (!error || typeof error !== 'object') return null
36
+ const status = (error as { status?: unknown }).status
37
+ return typeof status === 'number' ? status : null
38
+ }
39
+
40
+ const SEARCH_PAGE_SIZE = '50'
41
+ const RESOLVE_CHUNK_SIZE = 100
42
+
43
+ export function pickLookupString(item: LookupRemoteItem, ...keys: string[]): string {
44
+ for (const key of keys) {
45
+ const value = item[key]
46
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim()
47
+ }
48
+ return ''
49
+ }
50
+
51
+ async function loadItems(path: string, params: Record<string, string>): Promise<LookupRemoteItem[]> {
52
+ try {
53
+ const search = new URLSearchParams(params)
54
+ const payload = await readApiResultOrThrow<{ items?: LookupRemoteItem[] }>(`${path}?${search.toString()}`, undefined, {
55
+ fallback: { items: [] },
56
+ })
57
+ return Array.isArray(payload?.items) ? payload.items : []
58
+ } catch (err) {
59
+ const status = readErrorStatus(err)
60
+ logger.warn('ui.lookups.load.failed', { err, path, status })
61
+ throw new LookupLoadError(status === 401 || status === 403 ? 'forbidden' : 'failed', path)
62
+ }
63
+ }
64
+
65
+ function mapItems(items: LookupRemoteItem[], mapItem: (item: LookupRemoteItem) => LookupOption | null): LookupOption[] {
66
+ return items.map(mapItem).filter((option): option is LookupOption => option !== null)
67
+ }
68
+
69
+ function chunkIds(ids: readonly string[]): string[][] {
70
+ const unique = Array.from(new Set(ids))
71
+ const chunks: string[][] = []
72
+ for (let start = 0; start < unique.length; start += RESOLVE_CHUNK_SIZE) {
73
+ chunks.push(unique.slice(start, start + RESOLVE_CHUNK_SIZE))
74
+ }
75
+ return chunks
76
+ }
77
+
78
+ /** Resolves `ids` through the `?ids=` filter in chunks of at most 100, one request per chunk. */
79
+ async function resolveByIds(
80
+ path: string,
81
+ ids: readonly string[],
82
+ mapItem: (item: LookupRemoteItem) => LookupOption | null,
83
+ extraParams: Record<string, string> = {},
84
+ ): Promise<LookupOption[]> {
85
+ const chunks = chunkIds(ids)
86
+ const pages = await Promise.all(
87
+ chunks.map((chunk) =>
88
+ loadItems(path, { ...extraParams, ids: chunk.join(','), pageSize: String(chunk.length) }),
89
+ ),
90
+ )
91
+ return mapItems(pages.flat(), mapItem)
92
+ }
93
+
94
+ function searchParams(query: string | undefined, pageSize: string): Record<string, string> {
95
+ const trimmed = (query ?? '').trim()
96
+ return trimmed.length > 0 ? { search: trimmed, pageSize } : { pageSize }
97
+ }
98
+
99
+ export function createIdsLookupSource(
100
+ id: string,
101
+ path: string,
102
+ mapItem: (item: LookupRemoteItem) => LookupOption | null,
103
+ extraParams: Record<string, string> = {},
104
+ ): LookupSource {
105
+ return {
106
+ id,
107
+ search: async (query) => mapItems(await loadItems(path, { ...extraParams, ...searchParams(query, SEARCH_PAGE_SIZE) }), mapItem),
108
+ resolve: (ids) => resolveByIds(path, ids, mapItem, extraParams),
109
+ }
110
+ }
111
+
112
+ export function lookupLabelWithCode(label: string, code: string): string {
113
+ return code && code !== label ? `${label} (${code})` : label
114
+ }