@open-mercato/ui 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe

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,131 @@
1
+ import {
2
+ applyGroupHidden,
3
+ collectGroupItemKeys,
4
+ resolveGroupVisibility,
5
+ resolveItemKey,
6
+ type SidebarGroup,
7
+ } from '../customization-helpers'
8
+
9
+ // Hiding a whole group used to require toggling every item in it individually — `AppShell` drops a
10
+ // group only once *all* of its items are hidden. These cases cover the bulk toggle that does it in one
11
+ // action, and pin the invariant that matters for compatibility: it writes the same `hiddenItemIds`
12
+ // keys the per-item switches write, and nothing else.
13
+
14
+ const group: SidebarGroup = {
15
+ id: 'catalog.nav.group',
16
+ name: 'Catalog',
17
+ items: [
18
+ { href: '/backend/catalog/products', title: 'Products' },
19
+ {
20
+ href: '/backend/catalog/categories',
21
+ title: 'Categories',
22
+ children: [
23
+ { href: '/backend/catalog/categories/tree', title: 'Tree' },
24
+ { href: '/backend/catalog/categories/import', title: 'Import' },
25
+ ],
26
+ },
27
+ ],
28
+ }
29
+
30
+ const ALL_KEYS = [
31
+ '/backend/catalog/products',
32
+ '/backend/catalog/categories',
33
+ '/backend/catalog/categories/tree',
34
+ '/backend/catalog/categories/import',
35
+ ]
36
+
37
+ describe('collectGroupItemKeys', () => {
38
+ it('includes top-level items and every nested descendant', () => {
39
+ expect(collectGroupItemKeys(group).sort()).toEqual([...ALL_KEYS].sort())
40
+ })
41
+
42
+ it('returns nothing for an empty group', () => {
43
+ expect(collectGroupItemKeys({ id: 'empty', name: 'Empty', items: [] })).toEqual([])
44
+ })
45
+
46
+ it('prefers an explicit item id over the href, matching resolveItemKey', () => {
47
+ const withIds: SidebarGroup = {
48
+ id: 'g',
49
+ name: 'G',
50
+ items: [{ id: 'custom-id', href: '/backend/thing', title: 'Thing' }],
51
+ }
52
+
53
+ expect(collectGroupItemKeys(withIds)).toEqual([resolveItemKey(withIds.items[0])])
54
+ })
55
+ })
56
+
57
+ describe('resolveGroupVisibility', () => {
58
+ it('is visible when nothing is hidden', () => {
59
+ expect(resolveGroupVisibility(group, {})).toBe('visible')
60
+ })
61
+
62
+ it('is hidden only when every item including descendants is hidden', () => {
63
+ const allHidden = Object.fromEntries(ALL_KEYS.map((key) => [key, true]))
64
+
65
+ expect(resolveGroupVisibility(group, allHidden)).toBe('hidden')
66
+ })
67
+
68
+ it('is partial when some but not all items are hidden', () => {
69
+ expect(resolveGroupVisibility(group, { '/backend/catalog/products': true })).toBe('partial')
70
+ })
71
+
72
+ it('is partial when only a nested descendant is left visible', () => {
73
+ const allButOne = Object.fromEntries(
74
+ ALL_KEYS.filter((key) => key !== '/backend/catalog/categories/import').map((key) => [key, true]),
75
+ )
76
+
77
+ expect(resolveGroupVisibility(group, allButOne)).toBe('partial')
78
+ })
79
+
80
+ it('treats an empty group as visible rather than hidden', () => {
81
+ expect(resolveGroupVisibility({ id: 'empty', name: 'Empty', items: [] }, {})).toBe('visible')
82
+ })
83
+ })
84
+
85
+ describe('applyGroupHidden', () => {
86
+ it('hides every item in the group, descendants included', () => {
87
+ const next = applyGroupHidden({}, group, true)
88
+
89
+ expect(Object.keys(next).sort()).toEqual([...ALL_KEYS].sort())
90
+ expect(resolveGroupVisibility(group, next)).toBe('hidden')
91
+ })
92
+
93
+ it('clears every item in the group when shown', () => {
94
+ const hidden = applyGroupHidden({}, group, true)
95
+ const next = applyGroupHidden(hidden, group, false)
96
+
97
+ expect(next).toEqual({})
98
+ expect(resolveGroupVisibility(group, next)).toBe('visible')
99
+ })
100
+
101
+ it('leaves keys belonging to other groups untouched', () => {
102
+ const other = { '/backend/sales/orders': true }
103
+
104
+ expect(applyGroupHidden(other, group, true)['/backend/sales/orders']).toBe(true)
105
+ expect(applyGroupHidden(other, group, false)).toEqual(other)
106
+ })
107
+
108
+ it('does not mutate the input map', () => {
109
+ const input = { '/backend/sales/orders': true }
110
+ const snapshot = { ...input }
111
+
112
+ applyGroupHidden(input, group, true)
113
+
114
+ expect(input).toEqual(snapshot)
115
+ })
116
+
117
+ it('reaches the same state as toggling each item by hand', () => {
118
+ const byHand = ALL_KEYS.reduce<Record<string, boolean>>((acc, key) => {
119
+ acc[key] = true
120
+ return acc
121
+ }, {})
122
+
123
+ expect(applyGroupHidden({}, group, true)).toEqual(byHand)
124
+ })
125
+
126
+ it('is idempotent', () => {
127
+ const once = applyGroupHidden({}, group, true)
128
+
129
+ expect(applyGroupHidden(once, group, true)).toEqual(once)
130
+ })
131
+ })
@@ -156,6 +156,53 @@ export function applyCustomizationDraft(
156
156
  return result
157
157
  }
158
158
 
159
+ /** Every item key a group owns, including nested children. */
160
+ export function collectGroupItemKeys(group: SidebarGroup): string[] {
161
+ const keys: string[] = []
162
+ const walk = (items: SidebarItem[]) => {
163
+ for (const item of items) {
164
+ keys.push(resolveItemKey(item))
165
+ if (item.children?.length) walk(item.children)
166
+ }
167
+ }
168
+ walk(group.items)
169
+ return keys
170
+ }
171
+
172
+ /**
173
+ * Whether a group is fully visible, fully hidden, or in between. `AppShell` drops a group once every
174
+ * one of its items is hidden, so `'hidden'` is the state that removes the group from the sidebar.
175
+ */
176
+ export function resolveGroupVisibility(
177
+ group: SidebarGroup,
178
+ hiddenItemIds: Record<string, boolean>,
179
+ ): 'visible' | 'hidden' | 'partial' {
180
+ const keys = collectGroupItemKeys(group)
181
+ if (keys.length === 0) return 'visible'
182
+ const hiddenCount = keys.filter((key) => hiddenItemIds[key]).length
183
+ if (hiddenCount === 0) return 'visible'
184
+ if (hiddenCount === keys.length) return 'hidden'
185
+ return 'partial'
186
+ }
187
+
188
+ /**
189
+ * Hides or shows every item in a group at once. Writes the same `hiddenItemIds` keys the per-item
190
+ * switches use, so the resulting settings are indistinguishable from reaching this state by toggling
191
+ * each item by hand — no new persisted shape, no migration.
192
+ */
193
+ export function applyGroupHidden(
194
+ hiddenItemIds: Record<string, boolean>,
195
+ group: SidebarGroup,
196
+ hidden: boolean,
197
+ ): Record<string, boolean> {
198
+ const next = { ...hiddenItemIds }
199
+ for (const key of collectGroupItemKeys(group)) {
200
+ if (hidden) next[key] = true
201
+ else delete next[key]
202
+ }
203
+ return next
204
+ }
205
+
159
206
  /**
160
207
  * Filters groups to include only main sidebar items.
161
208
  * Excludes items with pageContext 'settings' or 'profile' from customization.
@@ -0,0 +1,95 @@
1
+ /** @jest-environment node */
2
+
3
+ const apiCall = jest.fn()
4
+
5
+ jest.mock('../apiCall', () => ({
6
+ apiCall: (...args: unknown[]) => apiCall(...args),
7
+ }))
8
+
9
+ jest.mock('../../fields/registry', () => ({
10
+ FieldRegistry: { getInput: () => null },
11
+ loadGeneratedFieldRegistrations: async () => {},
12
+ }))
13
+
14
+ import { buildFormFieldFromCustomFieldDef } from '../customFieldForms'
15
+
16
+ const okPage = (payload: Record<string, unknown>) => ({ ok: true, result: payload })
17
+
18
+ const requestedUrls = () => apiCall.mock.calls.map(([url]) => url as string)
19
+
20
+ const loadOptionsFor = (def: Parameters<typeof buildFormFieldFromCustomFieldDef>[0]) => {
21
+ const field = buildFormFieldFromCustomFieldDef(def)
22
+ const loadOptions = (field as { loadOptions?: (query?: string) => Promise<Array<{ value: string; label: string }>> })
23
+ .loadOptions
24
+ if (!loadOptions) throw new Error('[internal] expected the field to expose loadOptions')
25
+ return loadOptions
26
+ }
27
+
28
+ /**
29
+ * Guards the review finding on #4175: the entries route caps rows per response,
30
+ * so the generic `optionsUrl` loader behind every select/currency/relation and
31
+ * multi-dictionary custom field must page instead of dropping everything past
32
+ * the first page.
33
+ */
34
+ describe('custom field remote options paging', () => {
35
+ beforeEach(() => {
36
+ apiCall.mockReset()
37
+ })
38
+
39
+ it('walks every page of a dictionary-backed multi-select', async () => {
40
+ apiCall
41
+ .mockResolvedValueOnce(
42
+ okPage({ items: [{ value: 'a', label: 'Alpha' }], total: 2, hasMore: true }),
43
+ )
44
+ .mockResolvedValueOnce(
45
+ okPage({ items: [{ value: 'b', label: 'Beta' }], total: 2, hasMore: false }),
46
+ )
47
+
48
+ const options = await loadOptionsFor({
49
+ key: 'tags',
50
+ kind: 'dictionary',
51
+ multi: true,
52
+ dictionaryId: 'dict-1',
53
+ })()
54
+
55
+ expect(requestedUrls()).toEqual([
56
+ '/api/dictionaries/dict-1/entries',
57
+ '/api/dictionaries/dict-1/entries?offset=1',
58
+ ])
59
+ expect(options).toEqual([
60
+ { value: 'a', label: 'Alpha' },
61
+ { value: 'b', label: 'Beta' },
62
+ ])
63
+ })
64
+
65
+ it('keeps the typeahead query on every page it requests', async () => {
66
+ apiCall
67
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
68
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'ab', label: 'Able' }], hasMore: false }))
69
+
70
+ await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })('al')
71
+
72
+ expect(requestedUrls()[0]).toContain('query=al')
73
+ expect(requestedUrls()[1]).toContain('query=al')
74
+ expect(requestedUrls()[1]).toContain('offset=1')
75
+ })
76
+
77
+ it('issues one request for a route that does not report hasMore', async () => {
78
+ apiCall.mockResolvedValueOnce(okPage({ items: [{ value: 'usd', label: 'USD' }] }))
79
+
80
+ const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
81
+
82
+ expect(apiCall).toHaveBeenCalledTimes(1)
83
+ expect(options).toEqual([{ value: 'usd', label: 'USD' }])
84
+ })
85
+
86
+ it('returns no options when a page fails rather than a partial list', async () => {
87
+ apiCall
88
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
89
+ .mockResolvedValueOnce({ ok: false, result: { error: 'Forbidden' } })
90
+
91
+ const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
92
+
93
+ expect(options).toEqual([])
94
+ })
95
+ })
@@ -0,0 +1,103 @@
1
+ /** @jest-environment node */
2
+
3
+ const apiCall = jest.fn()
4
+
5
+ jest.mock('../apiCall', () => ({
6
+ apiCall: (...args: unknown[]) => apiCall(...args),
7
+ }))
8
+
9
+ import { fetchAllOffsetPages, MAX_OFFSET_PAGES } from '../fetchAllPages'
10
+
11
+ const okPage = (payload: Record<string, unknown>) => ({ ok: true, result: payload })
12
+
13
+ const requestedUrls = () => apiCall.mock.calls.map(([url]) => url as string)
14
+
15
+ describe('fetchAllOffsetPages', () => {
16
+ beforeEach(() => {
17
+ apiCall.mockReset()
18
+ })
19
+
20
+ it('issues a single untouched request when the route does not report hasMore', async () => {
21
+ apiCall.mockResolvedValueOnce(okPage({ items: [{ value: 'a' }, { value: 'b' }] }))
22
+
23
+ const result = await fetchAllOffsetPages('/api/options')
24
+
25
+ expect(requestedUrls()).toEqual(['/api/options'])
26
+ expect(result.ok).toBe(true)
27
+ expect(result.pageCount).toBe(1)
28
+ expect(result.items).toEqual([{ value: 'a' }, { value: 'b' }])
29
+ })
30
+
31
+ it('walks hasMore, advancing the offset by the rows already collected', async () => {
32
+ apiCall
33
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }, { value: 'b' }], hasMore: true }))
34
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'c' }], hasMore: false }))
35
+
36
+ const result = await fetchAllOffsetPages('/api/options')
37
+
38
+ expect(requestedUrls()).toEqual(['/api/options', '/api/options?offset=2'])
39
+ expect(result.items).toHaveLength(3)
40
+ expect(result.pageCount).toBe(2)
41
+ })
42
+
43
+ it('preserves the caller query string when it appends the offset', async () => {
44
+ apiCall
45
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
46
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false }))
47
+
48
+ await fetchAllOffsetPages('/api/options?query=ab&q=ab')
49
+
50
+ expect(requestedUrls()[1]).toBe('/api/options?query=ab&q=ab&offset=1')
51
+ })
52
+
53
+ it('continues from an offset the caller already put on the url', async () => {
54
+ apiCall
55
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
56
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false }))
57
+
58
+ await fetchAllOffsetPages('/api/options?offset=10')
59
+
60
+ expect(requestedUrls()).toEqual(['/api/options?offset=10', '/api/options?offset=11'])
61
+ })
62
+
63
+ it('exposes the first payload so callers can read page-invariant metadata', async () => {
64
+ apiCall
65
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true, sortMode: 'value_desc' }))
66
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false, sortMode: 'value_desc' }))
67
+
68
+ const result = await fetchAllOffsetPages('/api/options')
69
+
70
+ expect(result.firstPayload.sortMode).toBe('value_desc')
71
+ })
72
+
73
+ it('stops on an empty page so a route reporting hasMore forever cannot spin', async () => {
74
+ apiCall
75
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
76
+ .mockResolvedValueOnce(okPage({ items: [], hasMore: true }))
77
+
78
+ const result = await fetchAllOffsetPages('/api/options')
79
+
80
+ expect(apiCall).toHaveBeenCalledTimes(2)
81
+ expect(result.items).toHaveLength(1)
82
+ })
83
+
84
+ it('caps the walk at the page ceiling', async () => {
85
+ apiCall.mockResolvedValue(okPage({ items: [{ value: 'a' }], hasMore: true }))
86
+
87
+ const result = await fetchAllOffsetPages('/api/options')
88
+
89
+ expect(apiCall).toHaveBeenCalledTimes(MAX_OFFSET_PAGES)
90
+ expect(result.pageCount).toBe(MAX_OFFSET_PAGES)
91
+ })
92
+
93
+ it('reports a failed page instead of returning a silently truncated list', async () => {
94
+ apiCall
95
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
96
+ .mockResolvedValueOnce({ ok: false, result: { error: 'Forbidden' } })
97
+
98
+ const result = await fetchAllOffsetPages('/api/options')
99
+
100
+ expect(result.ok).toBe(false)
101
+ expect(result.errorPayload).toEqual({ error: 'Forbidden' })
102
+ })
103
+ })
@@ -9,7 +9,7 @@ import {
9
9
  fetchCustomFieldDefinitionsPayload,
10
10
  } from './customFieldDefs'
11
11
  import { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'
12
- import { apiCall } from './apiCall'
12
+ import { fetchAllOffsetPages } from './fetchAllPages'
13
13
  import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'
14
14
  import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
15
15
 
@@ -42,18 +42,22 @@ function buildOptionsUrl(base: string, query?: string): string {
42
42
  }
43
43
  }
44
44
 
45
- type OptionsResponse = { items?: unknown[] }
46
-
47
45
  async function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {
48
46
  try {
49
- const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })
50
- if (!call.ok) return []
51
- const payload = call.result ?? { items: [] }
52
- const items = Array.isArray(payload?.items) ? payload.items : []
53
- return items.map((it: any) => ({
54
- value: String(it?.value ?? it),
55
- label: String(it?.label ?? it?.value ?? it),
56
- }))
47
+ // Options routes bound how many rows one response carries (dictionary
48
+ // entries at 500), so walk every page instead of reading a single
49
+ // response — a stored value past the first page must stay selectable and
50
+ // resolvable to its label. Routes without `hasMore` still resolve in one
51
+ // request.
52
+ const pages = await fetchAllOffsetPages(url)
53
+ if (!pages.ok) return []
54
+ return pages.items.map((item) => {
55
+ const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null
56
+ return {
57
+ value: String(record?.value ?? item),
58
+ label: String(record?.label ?? record?.value ?? item),
59
+ }
60
+ })
57
61
  } catch {
58
62
  return []
59
63
  }
@@ -0,0 +1,93 @@
1
+ "use client"
2
+
3
+ import { apiCall } from './apiCall'
4
+
5
+ // Offset-paged list routes cap how many rows one response may carry, so a
6
+ // caller that needs the whole collection has to walk the pages. Bound the walk
7
+ // so a route reporting hasMore forever cannot hang the caller; at the 500-row
8
+ // dictionary ceiling this still covers 25k rows.
9
+ export const MAX_OFFSET_PAGES = 50
10
+
11
+ export type OffsetPagedPayload = Record<string, unknown>
12
+
13
+ export type OffsetPagedResult = {
14
+ ok: boolean
15
+ items: unknown[]
16
+ pageCount: number
17
+ firstPayload: OffsetPagedPayload
18
+ errorPayload: OffsetPagedPayload | null
19
+ }
20
+
21
+ const ABSOLUTE_URL_PATTERN = /^([a-z][a-z\d+\-.]*:)?\/\//i
22
+
23
+ type ParsedUrl = { url: URL; absolute: boolean }
24
+
25
+ function parseUrl(value: string): ParsedUrl | null {
26
+ try {
27
+ const absolute = ABSOLUTE_URL_PATTERN.test(value)
28
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
29
+ return { url: absolute ? new URL(value) : new URL(value, origin), absolute }
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function formatUrl(parsed: ParsedUrl): string {
36
+ return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`
37
+ }
38
+
39
+ function readOffset(value: string): number {
40
+ const parsed = parseUrl(value)
41
+ if (!parsed) return 0
42
+ const offset = Number.parseInt(parsed.url.searchParams.get('offset') ?? '', 10)
43
+ return Number.isFinite(offset) && offset > 0 ? offset : 0
44
+ }
45
+
46
+ function withOffset(value: string, offset: number): string {
47
+ const parsed = parseUrl(value)
48
+ if (!parsed) {
49
+ const separator = value.includes('?') ? '&' : '?'
50
+ return `${value}${separator}offset=${offset}`
51
+ }
52
+ parsed.url.searchParams.set('offset', String(offset))
53
+ return formatUrl(parsed)
54
+ }
55
+
56
+ /**
57
+ * Walk every page of an offset-paged list route and return the assembled items.
58
+ *
59
+ * A route that does not report `hasMore` resolves after a single request, so
60
+ * this is a drop-in replacement for a one-shot `apiCall` against any list
61
+ * endpoint. Ordering is preserved as the route returned it, page after page —
62
+ * callers that need a global order across page boundaries must re-sort.
63
+ */
64
+ export async function fetchAllOffsetPages(
65
+ url: string,
66
+ options: { maxPages?: number } = {},
67
+ ): Promise<OffsetPagedResult> {
68
+ const maxPages = options.maxPages ?? MAX_OFFSET_PAGES
69
+ const baseOffset = readOffset(url)
70
+ const items: unknown[] = []
71
+ let firstPayload: OffsetPagedPayload = {}
72
+ let pageCount = 0
73
+
74
+ while (pageCount < maxPages) {
75
+ // The first request goes out untouched so a route that caches only its
76
+ // default page still serves that page from cache.
77
+ const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length)
78
+ const call = await apiCall<OffsetPagedPayload>(pageUrl, undefined, { fallback: {} })
79
+ const payload = call.result ?? {}
80
+ if (!call.ok) {
81
+ return { ok: false, items, pageCount, firstPayload, errorPayload: payload }
82
+ }
83
+ pageCount += 1
84
+ if (pageCount === 1) firstPayload = payload
85
+ const pageItems = Array.isArray(payload.items) ? payload.items : []
86
+ items.push(...pageItems)
87
+ // An empty page also stops the walk: without it a route that keeps
88
+ // reporting hasMore while returning nothing would spin to the page cap.
89
+ if (payload.hasMore !== true || pageItems.length === 0) break
90
+ }
91
+
92
+ return { ok: true, items, pageCount, firstPayload, errorPayload: null }
93
+ }