@open-mercato/ui 0.9.1-develop.7473.1.de5b22888f → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +10 -3
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/LookupPickers.js +149 -0
- package/dist/backend/inputs/LookupPickers.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +4 -1
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/index.js +2 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/inputs/lookupSources.js +84 -0
- package/dist/backend/inputs/lookupSources.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +9 -0
- package/src/backend/__tests__/CrudForm.disabledFields.test.tsx +57 -0
- package/src/backend/inputs/LookupPickers.tsx +181 -0
- package/src/backend/inputs/TagsInput.tsx +4 -0
- package/src/backend/inputs/__tests__/LookupPickers.test.tsx +120 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +41 -0
- package/src/backend/inputs/__tests__/lookupSources.test.ts +87 -0
- package/src/backend/inputs/index.ts +2 -0
- package/src/backend/inputs/lookupSources.ts +114 -0
|
@@ -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
|
+
})
|
|
@@ -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
|
+
}
|