@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.
@@ -105,6 +105,33 @@ function applyCustomizationDraft(baseGroups, draft) {
105
105
  }
106
106
  return result;
107
107
  }
108
+ function collectGroupItemKeys(group) {
109
+ const keys = [];
110
+ const walk = (items) => {
111
+ for (const item of items) {
112
+ keys.push(resolveItemKey(item));
113
+ if (item.children?.length) walk(item.children);
114
+ }
115
+ };
116
+ walk(group.items);
117
+ return keys;
118
+ }
119
+ function resolveGroupVisibility(group, hiddenItemIds) {
120
+ const keys = collectGroupItemKeys(group);
121
+ if (keys.length === 0) return "visible";
122
+ const hiddenCount = keys.filter((key) => hiddenItemIds[key]).length;
123
+ if (hiddenCount === 0) return "visible";
124
+ if (hiddenCount === keys.length) return "hidden";
125
+ return "partial";
126
+ }
127
+ function applyGroupHidden(hiddenItemIds, group, hidden) {
128
+ const next = { ...hiddenItemIds };
129
+ for (const key of collectGroupItemKeys(group)) {
130
+ if (hidden) next[key] = true;
131
+ else delete next[key];
132
+ }
133
+ return next;
134
+ }
108
135
  function filterMainSidebarGroups(groups) {
109
136
  const isMainItem = (item) => {
110
137
  if (item.pageContext && item.pageContext !== "main") return false;
@@ -139,12 +166,15 @@ function collectSidebarDefaults(groups) {
139
166
  }
140
167
  export {
141
168
  applyCustomizationDraft,
169
+ applyGroupHidden,
142
170
  applyItemOrder,
143
171
  cloneSidebarGroups,
172
+ collectGroupItemKeys,
144
173
  collectSidebarDefaults,
145
174
  filterMainSidebarGroups,
146
175
  mergeGroupOrder,
147
176
  resolveGroupKey,
177
+ resolveGroupVisibility,
148
178
  resolveItemKey
149
179
  };
150
180
  //# sourceMappingURL=customization-helpers.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/sidebar/customization-helpers.ts"],
4
- "sourcesContent": ["import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'\n\nexport type SidebarItem = {\n id?: string\n href: string\n title: string\n defaultTitle?: string\n icon?: React.ReactNode\n iconName?: string\n iconMarkup?: string\n enabled?: boolean\n hidden?: boolean\n pageContext?: 'main' | 'admin' | 'settings' | 'profile'\n children?: SidebarItem[]\n}\n\nexport type SidebarGroup = {\n id?: string\n name: string\n defaultName?: string\n items: SidebarItem[]\n}\n\nexport type SidebarCustomizationDraft = {\n order: string[]\n groupLabels: Record<string, string>\n itemLabels: Record<string, string>\n hiddenItemIds: Record<string, boolean>\n /** Per-group ordered item keys. Missing items keep their natural position at the tail. */\n itemOrder: Record<string, string[]>\n}\n\nexport type SidebarRoleTarget = {\n id: string\n name: string\n hasPreference: boolean\n}\n\nexport function resolveGroupKey(group: SidebarGroup): string {\n if (group.id && group.id.length) return group.id\n if (group.defaultName && group.defaultName.length) return slugifySidebarId(group.defaultName)\n return slugifySidebarId(group.name)\n}\n\nexport function resolveItemKey(item: { id?: string; href: string }): string {\n const candidate = item.id?.trim()\n if (candidate && candidate.length > 0) return candidate\n return item.href\n}\n\nexport function cloneSidebarGroups(groups: SidebarGroup[]): SidebarGroup[] {\n const cloneItem = (item: SidebarItem): SidebarItem => ({\n id: item.id,\n href: item.href,\n title: item.title,\n defaultTitle: item.defaultTitle,\n icon: item.icon,\n iconName: item.iconName,\n iconMarkup: item.iconMarkup,\n enabled: item.enabled,\n hidden: item.hidden,\n pageContext: item.pageContext,\n children: item.children ? item.children.map((child) => cloneItem(child)) : undefined,\n })\n return groups.map((group) => ({\n id: group.id,\n name: group.name,\n defaultName: group.defaultName,\n items: group.items.map((item) => cloneItem(item)),\n }))\n}\n\nexport function mergeGroupOrder(preferred: string[], current: string[]): string[] {\n const seen = new Set<string>()\n const merged: string[] = []\n for (const id of preferred) {\n const trimmed = id.trim()\n if (!trimmed || seen.has(trimmed) || !current.includes(trimmed)) continue\n seen.add(trimmed)\n merged.push(trimmed)\n }\n for (const id of current) {\n if (seen.has(id)) continue\n seen.add(id)\n merged.push(id)\n }\n return merged\n}\n\n/** Reorders items by preferred keys; items missing from `preferred` keep their original\n * relative order at the tail. Drops keys that no longer exist. */\nexport function applyItemOrder<T>(items: T[], keyOf: (item: T) => string, preferred: string[] | undefined): T[] {\n if (!preferred || preferred.length === 0) return items\n const byKey = new Map<string, T>()\n for (const item of items) byKey.set(keyOf(item), item)\n const seen = new Set<string>()\n const ordered: T[] = []\n for (const key of preferred) {\n if (seen.has(key)) continue\n const match = byKey.get(key)\n if (!match) continue\n ordered.push(match)\n seen.add(key)\n }\n for (const item of items) {\n const key = keyOf(item)\n if (seen.has(key)) continue\n ordered.push(item)\n seen.add(key)\n }\n return ordered\n}\n\nfunction applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {\n const itemKey = resolveItemKey(item)\n const baseTitle = item.defaultTitle ?? item.title\n const override = draft.itemLabels[itemKey]?.trim()\n const children = item.children\n ? item.children.map((child) => applyItemDraft(child, draft))\n : undefined\n const hidden = draft.hiddenItemIds[itemKey] === true\n return {\n ...item,\n title: override && override.length > 0 ? override : baseTitle,\n hidden,\n children,\n }\n}\n\nexport function applyCustomizationDraft(\n baseGroups: SidebarGroup[],\n draft: SidebarCustomizationDraft,\n): SidebarGroup[] {\n const clones = cloneSidebarGroups(baseGroups)\n const byId = new Map<string, SidebarGroup>()\n for (const group of clones) {\n byId.set(resolveGroupKey(group), group)\n }\n const orderedIds = mergeGroupOrder(draft.order, Array.from(byId.keys()))\n const seen = new Set<string>()\n const result: SidebarGroup[] = []\n for (const id of orderedIds) {\n if (seen.has(id)) continue\n const group = byId.get(id)\n if (!group) continue\n seen.add(id)\n const baseName = group.defaultName ?? group.name\n const override = draft.groupLabels[id]?.trim()\n const orderedItems = applyItemOrder(group.items, resolveItemKey, draft.itemOrder?.[id])\n result.push({\n ...group,\n name: override && override.length > 0 ? override : baseName,\n items: orderedItems.map((item) => applyItemDraft(item, draft)),\n })\n }\n return result\n}\n\n/**\n * Filters groups to include only main sidebar items.\n * Excludes items with pageContext 'settings' or 'profile' from customization.\n * Per SPEC-007: Sidebar customization applies only to the main sidebar.\n */\nexport function filterMainSidebarGroups(groups: SidebarGroup[]): SidebarGroup[] {\n const isMainItem = (item: SidebarItem): boolean => {\n if (item.pageContext && item.pageContext !== 'main') return false\n return true\n }\n\n return groups\n .map((group) => ({\n ...group,\n items: group.items.filter(isMainItem).map((item) => ({\n ...item,\n children: item.children?.filter(isMainItem),\n })),\n }))\n .filter((group) => group.items.length > 0)\n}\n\nexport function collectSidebarDefaults(groups: SidebarGroup[]) {\n const groupDefaults = new Map<string, string>()\n const itemDefaults = new Map<string, string>()\n\n const visitItems = (items: SidebarItem[]) => {\n for (const item of items) {\n const key = resolveItemKey(item)\n const baseTitle = item.defaultTitle ?? item.title\n itemDefaults.set(key, baseTitle)\n // Backward-compatible alias for legacy stored href-based preferences.\n itemDefaults.set(item.href, baseTitle)\n if (item.children && item.children.length > 0) visitItems(item.children)\n }\n }\n\n for (const group of groups) {\n const key = resolveGroupKey(group)\n groupDefaults.set(key, group.defaultName ?? group.name)\n visitItems(group.items)\n }\n\n return { groupDefaults, itemDefaults }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'\n\nexport type SidebarItem = {\n id?: string\n href: string\n title: string\n defaultTitle?: string\n icon?: React.ReactNode\n iconName?: string\n iconMarkup?: string\n enabled?: boolean\n hidden?: boolean\n pageContext?: 'main' | 'admin' | 'settings' | 'profile'\n children?: SidebarItem[]\n}\n\nexport type SidebarGroup = {\n id?: string\n name: string\n defaultName?: string\n items: SidebarItem[]\n}\n\nexport type SidebarCustomizationDraft = {\n order: string[]\n groupLabels: Record<string, string>\n itemLabels: Record<string, string>\n hiddenItemIds: Record<string, boolean>\n /** Per-group ordered item keys. Missing items keep their natural position at the tail. */\n itemOrder: Record<string, string[]>\n}\n\nexport type SidebarRoleTarget = {\n id: string\n name: string\n hasPreference: boolean\n}\n\nexport function resolveGroupKey(group: SidebarGroup): string {\n if (group.id && group.id.length) return group.id\n if (group.defaultName && group.defaultName.length) return slugifySidebarId(group.defaultName)\n return slugifySidebarId(group.name)\n}\n\nexport function resolveItemKey(item: { id?: string; href: string }): string {\n const candidate = item.id?.trim()\n if (candidate && candidate.length > 0) return candidate\n return item.href\n}\n\nexport function cloneSidebarGroups(groups: SidebarGroup[]): SidebarGroup[] {\n const cloneItem = (item: SidebarItem): SidebarItem => ({\n id: item.id,\n href: item.href,\n title: item.title,\n defaultTitle: item.defaultTitle,\n icon: item.icon,\n iconName: item.iconName,\n iconMarkup: item.iconMarkup,\n enabled: item.enabled,\n hidden: item.hidden,\n pageContext: item.pageContext,\n children: item.children ? item.children.map((child) => cloneItem(child)) : undefined,\n })\n return groups.map((group) => ({\n id: group.id,\n name: group.name,\n defaultName: group.defaultName,\n items: group.items.map((item) => cloneItem(item)),\n }))\n}\n\nexport function mergeGroupOrder(preferred: string[], current: string[]): string[] {\n const seen = new Set<string>()\n const merged: string[] = []\n for (const id of preferred) {\n const trimmed = id.trim()\n if (!trimmed || seen.has(trimmed) || !current.includes(trimmed)) continue\n seen.add(trimmed)\n merged.push(trimmed)\n }\n for (const id of current) {\n if (seen.has(id)) continue\n seen.add(id)\n merged.push(id)\n }\n return merged\n}\n\n/** Reorders items by preferred keys; items missing from `preferred` keep their original\n * relative order at the tail. Drops keys that no longer exist. */\nexport function applyItemOrder<T>(items: T[], keyOf: (item: T) => string, preferred: string[] | undefined): T[] {\n if (!preferred || preferred.length === 0) return items\n const byKey = new Map<string, T>()\n for (const item of items) byKey.set(keyOf(item), item)\n const seen = new Set<string>()\n const ordered: T[] = []\n for (const key of preferred) {\n if (seen.has(key)) continue\n const match = byKey.get(key)\n if (!match) continue\n ordered.push(match)\n seen.add(key)\n }\n for (const item of items) {\n const key = keyOf(item)\n if (seen.has(key)) continue\n ordered.push(item)\n seen.add(key)\n }\n return ordered\n}\n\nfunction applyItemDraft(item: SidebarItem, draft: SidebarCustomizationDraft): SidebarItem {\n const itemKey = resolveItemKey(item)\n const baseTitle = item.defaultTitle ?? item.title\n const override = draft.itemLabels[itemKey]?.trim()\n const children = item.children\n ? item.children.map((child) => applyItemDraft(child, draft))\n : undefined\n const hidden = draft.hiddenItemIds[itemKey] === true\n return {\n ...item,\n title: override && override.length > 0 ? override : baseTitle,\n hidden,\n children,\n }\n}\n\nexport function applyCustomizationDraft(\n baseGroups: SidebarGroup[],\n draft: SidebarCustomizationDraft,\n): SidebarGroup[] {\n const clones = cloneSidebarGroups(baseGroups)\n const byId = new Map<string, SidebarGroup>()\n for (const group of clones) {\n byId.set(resolveGroupKey(group), group)\n }\n const orderedIds = mergeGroupOrder(draft.order, Array.from(byId.keys()))\n const seen = new Set<string>()\n const result: SidebarGroup[] = []\n for (const id of orderedIds) {\n if (seen.has(id)) continue\n const group = byId.get(id)\n if (!group) continue\n seen.add(id)\n const baseName = group.defaultName ?? group.name\n const override = draft.groupLabels[id]?.trim()\n const orderedItems = applyItemOrder(group.items, resolveItemKey, draft.itemOrder?.[id])\n result.push({\n ...group,\n name: override && override.length > 0 ? override : baseName,\n items: orderedItems.map((item) => applyItemDraft(item, draft)),\n })\n }\n return result\n}\n\n/** Every item key a group owns, including nested children. */\nexport function collectGroupItemKeys(group: SidebarGroup): string[] {\n const keys: string[] = []\n const walk = (items: SidebarItem[]) => {\n for (const item of items) {\n keys.push(resolveItemKey(item))\n if (item.children?.length) walk(item.children)\n }\n }\n walk(group.items)\n return keys\n}\n\n/**\n * Whether a group is fully visible, fully hidden, or in between. `AppShell` drops a group once every\n * one of its items is hidden, so `'hidden'` is the state that removes the group from the sidebar.\n */\nexport function resolveGroupVisibility(\n group: SidebarGroup,\n hiddenItemIds: Record<string, boolean>,\n): 'visible' | 'hidden' | 'partial' {\n const keys = collectGroupItemKeys(group)\n if (keys.length === 0) return 'visible'\n const hiddenCount = keys.filter((key) => hiddenItemIds[key]).length\n if (hiddenCount === 0) return 'visible'\n if (hiddenCount === keys.length) return 'hidden'\n return 'partial'\n}\n\n/**\n * Hides or shows every item in a group at once. Writes the same `hiddenItemIds` keys the per-item\n * switches use, so the resulting settings are indistinguishable from reaching this state by toggling\n * each item by hand \u2014 no new persisted shape, no migration.\n */\nexport function applyGroupHidden(\n hiddenItemIds: Record<string, boolean>,\n group: SidebarGroup,\n hidden: boolean,\n): Record<string, boolean> {\n const next = { ...hiddenItemIds }\n for (const key of collectGroupItemKeys(group)) {\n if (hidden) next[key] = true\n else delete next[key]\n }\n return next\n}\n\n/**\n * Filters groups to include only main sidebar items.\n * Excludes items with pageContext 'settings' or 'profile' from customization.\n * Per SPEC-007: Sidebar customization applies only to the main sidebar.\n */\nexport function filterMainSidebarGroups(groups: SidebarGroup[]): SidebarGroup[] {\n const isMainItem = (item: SidebarItem): boolean => {\n if (item.pageContext && item.pageContext !== 'main') return false\n return true\n }\n\n return groups\n .map((group) => ({\n ...group,\n items: group.items.filter(isMainItem).map((item) => ({\n ...item,\n children: item.children?.filter(isMainItem),\n })),\n }))\n .filter((group) => group.items.length > 0)\n}\n\nexport function collectSidebarDefaults(groups: SidebarGroup[]) {\n const groupDefaults = new Map<string, string>()\n const itemDefaults = new Map<string, string>()\n\n const visitItems = (items: SidebarItem[]) => {\n for (const item of items) {\n const key = resolveItemKey(item)\n const baseTitle = item.defaultTitle ?? item.title\n itemDefaults.set(key, baseTitle)\n // Backward-compatible alias for legacy stored href-based preferences.\n itemDefaults.set(item.href, baseTitle)\n if (item.children && item.children.length > 0) visitItems(item.children)\n }\n }\n\n for (const group of groups) {\n const key = resolveGroupKey(group)\n groupDefaults.set(key, group.defaultName ?? group.name)\n visitItems(group.items)\n }\n\n return { groupDefaults, itemDefaults }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,7 +3,7 @@ import {
3
3
  fetchCustomFieldDefinitionsPayload
4
4
  } from "./customFieldDefs.js";
5
5
  import { FieldRegistry, loadGeneratedFieldRegistrations } from "../fields/registry.js";
6
- import { apiCall } from "./apiCall.js";
6
+ import { fetchAllOffsetPages } from "./fetchAllPages.js";
7
7
  import { normalizeCustomFieldOptions } from "@open-mercato/shared/modules/entities/options";
8
8
  import { CURRENCY_OPTIONS_URL } from "@open-mercato/shared/modules/entities/kinds";
9
9
  let registryReady = null;
@@ -34,14 +34,15 @@ function buildOptionsUrl(base, query) {
34
34
  }
35
35
  async function loadRemoteOptions(url) {
36
36
  try {
37
- const call = await apiCall(url, void 0, { fallback: { items: [] } });
38
- if (!call.ok) return [];
39
- const payload = call.result ?? { items: [] };
40
- const items = Array.isArray(payload?.items) ? payload.items : [];
41
- return items.map((it) => ({
42
- value: String(it?.value ?? it),
43
- label: String(it?.label ?? it?.value ?? it)
44
- }));
37
+ const pages = await fetchAllOffsetPages(url);
38
+ if (!pages.ok) return [];
39
+ return pages.items.map((item) => {
40
+ const record = item && typeof item === "object" ? item : null;
41
+ return {
42
+ value: String(record?.value ?? item),
43
+ label: String(record?.label ?? record?.value ?? item)
44
+ };
45
+ });
45
46
  } catch {
46
47
  return [];
47
48
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/utils/customFieldForms.ts"],
4
- "sourcesContent": ["import type { CrudField } from '../CrudForm'\nimport type {\n CustomFieldDefDto,\n CustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport {\n filterCustomFieldDefs,\n fetchCustomFieldDefs,\n fetchCustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'\nimport { apiCall } from './apiCall'\nimport { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'\nimport { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'\n\nlet registryReady: Promise<void> | null = null\n\nasync function ensureFieldRegistryReady() {\n if (!registryReady) {\n registryReady = loadGeneratedFieldRegistrations().catch((err) => {\n registryReady = null\n throw err\n })\n }\n await registryReady\n}\n\nfunction buildOptionsUrl(base: string, query?: string): string {\n if (!query) return base\n try {\n const isAbsolute = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i.test(base)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n const url = isAbsolute ? new URL(base) : new URL(base, origin)\n if (!url.searchParams.has('query')) url.searchParams.append('query', query)\n if (!url.searchParams.has('q')) url.searchParams.append('q', query)\n if (isAbsolute) return url.toString()\n return `${url.pathname}${url.search}`\n } catch {\n const sep = base.includes('?') ? '&' : '?'\n if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`\n return `${base}${sep}query=${encodeURIComponent(query)}`\n }\n}\n\ntype OptionsResponse = { items?: unknown[] }\n\nasync function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {\n try {\n const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })\n if (!call.ok) return []\n const payload = call.result ?? { items: [] }\n const items = Array.isArray(payload?.items) ? payload.items : []\n return items.map((it: any) => ({\n value: String(it?.value ?? it),\n label: String(it?.label ?? it?.value ?? it),\n }))\n } catch {\n return []\n }\n}\n\nexport function buildFormFieldFromCustomFieldDef(\n def: CustomFieldDefDto,\n opts?: { bareIds?: boolean }\n): CrudField | null {\n const id = opts?.bareIds ? def.key : `cf_${def.key}`\n const label = def.label || def.key\n const required = Array.isArray((def as any).validation)\n ? ((def as any).validation as any[]).some((rule) => rule && rule.rule === 'required')\n : false\n const baseProps = {\n description: def.description,\n required,\n ...(def.defaultValue !== undefined && def.defaultValue !== null ? { defaultValue: def.defaultValue } : {}),\n }\n\n switch (def.kind) {\n case 'boolean':\n return { id, label, type: 'checkbox', ...baseProps }\n case 'integer':\n case 'float':\n return { id, label, type: 'number', ...baseProps }\n case 'date':\n return { id, label, type: 'datepicker', ...baseProps }\n case 'datetime':\n return { id, label, type: 'datetime', ...baseProps }\n case 'multiline': {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n case 'select':\n case 'currency':\n case 'relation':\n return {\n id,\n label,\n type: 'select',\n ...baseProps,\n options: normalizeCustomFieldOptions(def.options || []).map((option) => ({\n value: option.value,\n label: option.label,\n })),\n multiple: !!def.multi,\n ...(def.kind === 'currency'\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(CURRENCY_OPTIONS_URL, query)\n return loadRemoteOptions(url)\n },\n }\n : def.optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n ...(def.multi && def.input === 'listbox' ? ({ listbox: true } as any) : {}),\n }\n case 'dictionary': {\n if (!def.multi) {\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n const optionsUrl = def.optionsUrl || (def.dictionaryId ? `/api/dictionaries/${def.dictionaryId}/entries` : undefined)\n return {\n id,\n label,\n type: 'select',\n description: def.description,\n required,\n options: [],\n multiple: true,\n listbox: true,\n ...(optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(optionsUrl, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n }\n }\n default: {\n if (def.kind === 'text' && def.multi) {\n const base: any = { id, label, type: 'tags', ...baseProps }\n const resolvedOptions = normalizeCustomFieldOptions(def.options || [])\n if (resolvedOptions.length > 0) {\n base.options = resolvedOptions.map((option) => ({ value: option.value, label: option.label }))\n }\n if (def.optionsUrl) {\n base.loadOptions = async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n }\n }\n return base\n }\n if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n }\n}\n\nexport function buildFormFieldsFromCustomFields(\n defs: CustomFieldDefDto[],\n opts?: { bareIds?: boolean }\n): CrudField[] {\n const fields: CrudField[] = []\n const visible = filterCustomFieldDefs(defs, 'form')\n const seenKeys = new Set<string>()\n for (const def of visible) {\n const keyLower = String(def.key).toLowerCase()\n if (seenKeys.has(keyLower)) continue\n seenKeys.add(keyLower)\n const field = buildFormFieldFromCustomFieldDef(def, opts)\n if (field) fields.push(field)\n }\n return fields\n}\n\nexport async function fetchCustomFieldFormStructure(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n await ensureFieldRegistryReady()\n const metadata = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl)\n const definitions = Array.isArray(metadata.items) ? metadata.items : []\n const fields = buildFormFieldsFromCustomFields(definitions, options)\n return { fields, definitions, metadata }\n}\n\nexport async function fetchCustomFieldFormFields(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<CrudField[]> {\n const { fields } = await fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n return fields\n}\n\nexport async function fetchCustomFieldFormFieldsWithDefinitions(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n return fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { CrudField } from '../CrudForm'\nimport type {\n CustomFieldDefDto,\n CustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport {\n filterCustomFieldDefs,\n fetchCustomFieldDefs,\n fetchCustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'\nimport { fetchAllOffsetPages } from './fetchAllPages'\nimport { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'\nimport { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'\n\nlet registryReady: Promise<void> | null = null\n\nasync function ensureFieldRegistryReady() {\n if (!registryReady) {\n registryReady = loadGeneratedFieldRegistrations().catch((err) => {\n registryReady = null\n throw err\n })\n }\n await registryReady\n}\n\nfunction buildOptionsUrl(base: string, query?: string): string {\n if (!query) return base\n try {\n const isAbsolute = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i.test(base)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n const url = isAbsolute ? new URL(base) : new URL(base, origin)\n if (!url.searchParams.has('query')) url.searchParams.append('query', query)\n if (!url.searchParams.has('q')) url.searchParams.append('q', query)\n if (isAbsolute) return url.toString()\n return `${url.pathname}${url.search}`\n } catch {\n const sep = base.includes('?') ? '&' : '?'\n if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`\n return `${base}${sep}query=${encodeURIComponent(query)}`\n }\n}\n\nasync function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {\n try {\n // Options routes bound how many rows one response carries (dictionary\n // entries at 500), so walk every page instead of reading a single\n // response \u2014 a stored value past the first page must stay selectable and\n // resolvable to its label. Routes without `hasMore` still resolve in one\n // request.\n const pages = await fetchAllOffsetPages(url)\n if (!pages.ok) return []\n return pages.items.map((item) => {\n const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null\n return {\n value: String(record?.value ?? item),\n label: String(record?.label ?? record?.value ?? item),\n }\n })\n } catch {\n return []\n }\n}\n\nexport function buildFormFieldFromCustomFieldDef(\n def: CustomFieldDefDto,\n opts?: { bareIds?: boolean }\n): CrudField | null {\n const id = opts?.bareIds ? def.key : `cf_${def.key}`\n const label = def.label || def.key\n const required = Array.isArray((def as any).validation)\n ? ((def as any).validation as any[]).some((rule) => rule && rule.rule === 'required')\n : false\n const baseProps = {\n description: def.description,\n required,\n ...(def.defaultValue !== undefined && def.defaultValue !== null ? { defaultValue: def.defaultValue } : {}),\n }\n\n switch (def.kind) {\n case 'boolean':\n return { id, label, type: 'checkbox', ...baseProps }\n case 'integer':\n case 'float':\n return { id, label, type: 'number', ...baseProps }\n case 'date':\n return { id, label, type: 'datepicker', ...baseProps }\n case 'datetime':\n return { id, label, type: 'datetime', ...baseProps }\n case 'multiline': {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n case 'select':\n case 'currency':\n case 'relation':\n return {\n id,\n label,\n type: 'select',\n ...baseProps,\n options: normalizeCustomFieldOptions(def.options || []).map((option) => ({\n value: option.value,\n label: option.label,\n })),\n multiple: !!def.multi,\n ...(def.kind === 'currency'\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(CURRENCY_OPTIONS_URL, query)\n return loadRemoteOptions(url)\n },\n }\n : def.optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n ...(def.multi && def.input === 'listbox' ? ({ listbox: true } as any) : {}),\n }\n case 'dictionary': {\n if (!def.multi) {\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n const optionsUrl = def.optionsUrl || (def.dictionaryId ? `/api/dictionaries/${def.dictionaryId}/entries` : undefined)\n return {\n id,\n label,\n type: 'select',\n description: def.description,\n required,\n options: [],\n multiple: true,\n listbox: true,\n ...(optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(optionsUrl, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n }\n }\n default: {\n if (def.kind === 'text' && def.multi) {\n const base: any = { id, label, type: 'tags', ...baseProps }\n const resolvedOptions = normalizeCustomFieldOptions(def.options || [])\n if (resolvedOptions.length > 0) {\n base.options = resolvedOptions.map((option) => ({ value: option.value, label: option.label }))\n }\n if (def.optionsUrl) {\n base.loadOptions = async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n }\n }\n return base\n }\n if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n }\n}\n\nexport function buildFormFieldsFromCustomFields(\n defs: CustomFieldDefDto[],\n opts?: { bareIds?: boolean }\n): CrudField[] {\n const fields: CrudField[] = []\n const visible = filterCustomFieldDefs(defs, 'form')\n const seenKeys = new Set<string>()\n for (const def of visible) {\n const keyLower = String(def.key).toLowerCase()\n if (seenKeys.has(keyLower)) continue\n seenKeys.add(keyLower)\n const field = buildFormFieldFromCustomFieldDef(def, opts)\n if (field) fields.push(field)\n }\n return fields\n}\n\nexport async function fetchCustomFieldFormStructure(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n await ensureFieldRegistryReady()\n const metadata = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl)\n const definitions = Array.isArray(metadata.items) ? metadata.items : []\n const fields = buildFormFieldsFromCustomFields(definitions, options)\n return { fields, definitions, metadata }\n}\n\nexport async function fetchCustomFieldFormFields(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<CrudField[]> {\n const { fields } = await fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n return fields\n}\n\nexport async function fetchCustomFieldFormFieldsWithDefinitions(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n return fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { apiCall } from "./apiCall.js";
3
+ const MAX_OFFSET_PAGES = 50;
4
+ const ABSOLUTE_URL_PATTERN = /^([a-z][a-z\d+\-.]*:)?\/\//i;
5
+ function parseUrl(value) {
6
+ try {
7
+ const absolute = ABSOLUTE_URL_PATTERN.test(value);
8
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
9
+ return { url: absolute ? new URL(value) : new URL(value, origin), absolute };
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function formatUrl(parsed) {
15
+ return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`;
16
+ }
17
+ function readOffset(value) {
18
+ const parsed = parseUrl(value);
19
+ if (!parsed) return 0;
20
+ const offset = Number.parseInt(parsed.url.searchParams.get("offset") ?? "", 10);
21
+ return Number.isFinite(offset) && offset > 0 ? offset : 0;
22
+ }
23
+ function withOffset(value, offset) {
24
+ const parsed = parseUrl(value);
25
+ if (!parsed) {
26
+ const separator = value.includes("?") ? "&" : "?";
27
+ return `${value}${separator}offset=${offset}`;
28
+ }
29
+ parsed.url.searchParams.set("offset", String(offset));
30
+ return formatUrl(parsed);
31
+ }
32
+ async function fetchAllOffsetPages(url, options = {}) {
33
+ const maxPages = options.maxPages ?? MAX_OFFSET_PAGES;
34
+ const baseOffset = readOffset(url);
35
+ const items = [];
36
+ let firstPayload = {};
37
+ let pageCount = 0;
38
+ while (pageCount < maxPages) {
39
+ const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length);
40
+ const call = await apiCall(pageUrl, void 0, { fallback: {} });
41
+ const payload = call.result ?? {};
42
+ if (!call.ok) {
43
+ return { ok: false, items, pageCount, firstPayload, errorPayload: payload };
44
+ }
45
+ pageCount += 1;
46
+ if (pageCount === 1) firstPayload = payload;
47
+ const pageItems = Array.isArray(payload.items) ? payload.items : [];
48
+ items.push(...pageItems);
49
+ if (payload.hasMore !== true || pageItems.length === 0) break;
50
+ }
51
+ return { ok: true, items, pageCount, firstPayload, errorPayload: null };
52
+ }
53
+ export {
54
+ MAX_OFFSET_PAGES,
55
+ fetchAllOffsetPages
56
+ };
57
+ //# sourceMappingURL=fetchAllPages.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/utils/fetchAllPages.ts"],
4
+ "sourcesContent": ["\"use client\"\n\nimport { apiCall } from './apiCall'\n\n// Offset-paged list routes cap how many rows one response may carry, so a\n// caller that needs the whole collection has to walk the pages. Bound the walk\n// so a route reporting hasMore forever cannot hang the caller; at the 500-row\n// dictionary ceiling this still covers 25k rows.\nexport const MAX_OFFSET_PAGES = 50\n\nexport type OffsetPagedPayload = Record<string, unknown>\n\nexport type OffsetPagedResult = {\n ok: boolean\n items: unknown[]\n pageCount: number\n firstPayload: OffsetPagedPayload\n errorPayload: OffsetPagedPayload | null\n}\n\nconst ABSOLUTE_URL_PATTERN = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i\n\ntype ParsedUrl = { url: URL; absolute: boolean }\n\nfunction parseUrl(value: string): ParsedUrl | null {\n try {\n const absolute = ABSOLUTE_URL_PATTERN.test(value)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n return { url: absolute ? new URL(value) : new URL(value, origin), absolute }\n } catch {\n return null\n }\n}\n\nfunction formatUrl(parsed: ParsedUrl): string {\n return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`\n}\n\nfunction readOffset(value: string): number {\n const parsed = parseUrl(value)\n if (!parsed) return 0\n const offset = Number.parseInt(parsed.url.searchParams.get('offset') ?? '', 10)\n return Number.isFinite(offset) && offset > 0 ? offset : 0\n}\n\nfunction withOffset(value: string, offset: number): string {\n const parsed = parseUrl(value)\n if (!parsed) {\n const separator = value.includes('?') ? '&' : '?'\n return `${value}${separator}offset=${offset}`\n }\n parsed.url.searchParams.set('offset', String(offset))\n return formatUrl(parsed)\n}\n\n/**\n * Walk every page of an offset-paged list route and return the assembled items.\n *\n * A route that does not report `hasMore` resolves after a single request, so\n * this is a drop-in replacement for a one-shot `apiCall` against any list\n * endpoint. Ordering is preserved as the route returned it, page after page \u2014\n * callers that need a global order across page boundaries must re-sort.\n */\nexport async function fetchAllOffsetPages(\n url: string,\n options: { maxPages?: number } = {},\n): Promise<OffsetPagedResult> {\n const maxPages = options.maxPages ?? MAX_OFFSET_PAGES\n const baseOffset = readOffset(url)\n const items: unknown[] = []\n let firstPayload: OffsetPagedPayload = {}\n let pageCount = 0\n\n while (pageCount < maxPages) {\n // The first request goes out untouched so a route that caches only its\n // default page still serves that page from cache.\n const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length)\n const call = await apiCall<OffsetPagedPayload>(pageUrl, undefined, { fallback: {} })\n const payload = call.result ?? {}\n if (!call.ok) {\n return { ok: false, items, pageCount, firstPayload, errorPayload: payload }\n }\n pageCount += 1\n if (pageCount === 1) firstPayload = payload\n const pageItems = Array.isArray(payload.items) ? payload.items : []\n items.push(...pageItems)\n // An empty page also stops the walk: without it a route that keeps\n // reporting hasMore while returning nothing would spin to the page cap.\n if (payload.hasMore !== true || pageItems.length === 0) break\n }\n\n return { ok: true, items, pageCount, firstPayload, errorPayload: null }\n}\n"],
5
+ "mappings": ";AAEA,SAAS,eAAe;AAMjB,MAAM,mBAAmB;AAYhC,MAAM,uBAAuB;AAI7B,SAAS,SAAS,OAAiC;AACjD,MAAI;AACF,UAAM,WAAW,qBAAqB,KAAK,KAAK;AAChD,UAAM,SAAS,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AACxE,WAAO,EAAE,KAAK,WAAW,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,OAAO,MAAM,GAAG,SAAS;AAAA,EAC7E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,UAAU,QAA2B;AAC5C,SAAO,OAAO,WAAW,OAAO,IAAI,SAAS,IAAI,GAAG,OAAO,IAAI,QAAQ,GAAG,OAAO,IAAI,MAAM;AAC7F;AAEA,SAAS,WAAW,OAAuB;AACzC,QAAM,SAAS,SAAS,KAAK;AAC7B,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,SAAS,OAAO,SAAS,OAAO,IAAI,aAAa,IAAI,QAAQ,KAAK,IAAI,EAAE;AAC9E,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEA,SAAS,WAAW,OAAe,QAAwB;AACzD,QAAM,SAAS,SAAS,KAAK;AAC7B,MAAI,CAAC,QAAQ;AACX,UAAM,YAAY,MAAM,SAAS,GAAG,IAAI,MAAM;AAC9C,WAAO,GAAG,KAAK,GAAG,SAAS,UAAU,MAAM;AAAA,EAC7C;AACA,SAAO,IAAI,aAAa,IAAI,UAAU,OAAO,MAAM,CAAC;AACpD,SAAO,UAAU,MAAM;AACzB;AAUA,eAAsB,oBACpB,KACA,UAAiC,CAAC,GACN;AAC5B,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,aAAa,WAAW,GAAG;AACjC,QAAM,QAAmB,CAAC;AAC1B,MAAI,eAAmC,CAAC;AACxC,MAAI,YAAY;AAEhB,SAAO,YAAY,UAAU;AAG3B,UAAM,UAAU,cAAc,IAAI,MAAM,WAAW,KAAK,aAAa,MAAM,MAAM;AACjF,UAAM,OAAO,MAAM,QAA4B,SAAS,QAAW,EAAE,UAAU,CAAC,EAAE,CAAC;AACnF,UAAM,UAAU,KAAK,UAAU,CAAC;AAChC,QAAI,CAAC,KAAK,IAAI;AACZ,aAAO,EAAE,IAAI,OAAO,OAAO,WAAW,cAAc,cAAc,QAAQ;AAAA,IAC5E;AACA,iBAAa;AACb,QAAI,cAAc,EAAG,gBAAe;AACpC,UAAM,YAAY,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,QAAQ,CAAC;AAClE,UAAM,KAAK,GAAG,SAAS;AAGvB,QAAI,QAAQ,YAAY,QAAQ,UAAU,WAAW,EAAG;AAAA,EAC1D;AAEA,SAAO,EAAE,IAAI,MAAM,OAAO,WAAW,cAAc,cAAc,KAAK;AACxE;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6726.1.983ae8a07e",
3
+ "version": "0.6.7-develop.6749.1.6b54c56dfe",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6726.1.983ae8a07e",
158
+ "@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.7-develop.6726.1.983ae8a07e",
164
+ "@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -2402,10 +2402,19 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2402
2402
 
2403
2403
  const handleFieldsetSelectionChange = React.useCallback(
2404
2404
  (entityId: string, nextCode: string | null) => {
2405
+ // The fieldset selector never renders an empty option, so a user can never
2406
+ // pick "no fieldset". An empty/null value here only comes from Radix's
2407
+ // hidden native <select>, which fires a spurious onChange when the
2408
+ // controlled value is changed programmatically after mount (e.g. when the
2409
+ // persisted customFieldsetCode arrives via async initialValues and the
2410
+ // binding hydrates the selection). Honoring that reset would wipe both the
2411
+ // selection and the bound value, collapsing the form back to the default
2412
+ // fieldset (#2646). Ignore it.
2413
+ if (!nextCode) return
2405
2414
  setCfFieldsetSelections((prev) => ({ ...prev, [entityId]: nextCode }))
2406
2415
  const bindingKey = customFieldsetBindings?.[entityId]?.valueKey
2407
2416
  if (bindingKey) {
2408
- setValue(bindingKey, nextCode ?? undefined)
2417
+ setValue(bindingKey, nextCode)
2409
2418
  }
2410
2419
  },
2411
2420
  [customFieldsetBindings, setValue],
@@ -0,0 +1,158 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ const fetchCustomFieldFormStructureMock = jest.fn()
5
+ const buildFormFieldFromCustomFieldDefMock = jest.fn()
6
+ const triggerInjectionEventMock = jest.fn(async (_event: string, data: Record<string, unknown>) => ({
7
+ ok: true,
8
+ data,
9
+ }))
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn() }),
13
+ usePathname: () => '/',
14
+ useSearchParams: () => new URLSearchParams(),
15
+ }))
16
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
17
+ jest.mock('../confirm-dialog', () => ({
18
+ useConfirmDialog: () => ({
19
+ confirm: jest.fn().mockResolvedValue(true),
20
+ ConfirmDialogElement: null,
21
+ }),
22
+ }))
23
+ jest.mock('../injection/InjectionSpot', () => ({
24
+ __esModule: true,
25
+ InjectionSpot: () => null,
26
+ useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
27
+ useInjectionSpotEvents: () => ({ triggerEvent: triggerInjectionEventMock }),
28
+ }))
29
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
30
+ __esModule: true,
31
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
32
+ }))
33
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
34
+ const React = require('react')
35
+ return {
36
+ __esModule: true,
37
+ FieldDefinitionsManager: React.forwardRef(() => <div>Field definitions manager</div>),
38
+ }
39
+ })
40
+ jest.mock('../utils/customFieldForms', () => ({
41
+ __esModule: true,
42
+ buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
43
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
44
+ fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
45
+ }))
46
+
47
+ import * as React from 'react'
48
+ import { act, waitFor } from '@testing-library/react'
49
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
50
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
51
+
52
+ const ENTITY_ID = 'resources:resources_resource'
53
+ const GENERAL_CODE = 'resources_resource_general'
54
+ const LAPTOP_CODE = 'resources_resource_laptop'
55
+
56
+ const fields: CrudField[] = [{ id: 'name', label: 'Name', type: 'text' }]
57
+ const groups: CrudFormGroup[] = [
58
+ { id: 'details', title: 'Details', fields: ['name'] },
59
+ { id: 'custom', title: 'Custom fields', kind: 'customFields' },
60
+ ]
61
+
62
+ function configureTwoFieldsets() {
63
+ buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
64
+ id: `cf_${definition.key}`,
65
+ label: definition.label ?? definition.key,
66
+ type: 'text',
67
+ }))
68
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
69
+ fields: [],
70
+ definitions: [
71
+ {
72
+ entityId: ENTITY_ID,
73
+ key: 'asset_tag',
74
+ label: 'Asset tag',
75
+ kind: 'text',
76
+ fieldsets: [GENERAL_CODE],
77
+ },
78
+ {
79
+ entityId: ENTITY_ID,
80
+ key: 'serial_number',
81
+ label: 'Serial number',
82
+ kind: 'text',
83
+ fieldsets: [LAPTOP_CODE],
84
+ },
85
+ ],
86
+ metadata: {
87
+ items: [],
88
+ fieldsetsByEntity: {
89
+ [ENTITY_ID]: [
90
+ { code: GENERAL_CODE, label: 'General' },
91
+ { code: LAPTOP_CODE, label: 'Laptops' },
92
+ ],
93
+ },
94
+ entitySettings: {
95
+ [ENTITY_ID]: { singleFieldsetPerRecord: true },
96
+ },
97
+ },
98
+ })
99
+ }
100
+
101
+ function DelayedHost({
102
+ initialValues,
103
+ }: {
104
+ initialValues: Record<string, unknown> | undefined
105
+ }) {
106
+ return (
107
+ <CrudForm
108
+ embedded
109
+ title="Resource"
110
+ entityId={ENTITY_ID}
111
+ fields={fields}
112
+ groups={groups}
113
+ customFieldsetBindings={{ [ENTITY_ID]: { valueKey: 'customFieldsetCode' } }}
114
+ initialValues={initialValues ?? undefined}
115
+ isLoading={!initialValues}
116
+ onSubmit={() => {}}
117
+ />
118
+ )
119
+ }
120
+
121
+ describe('CrudForm customFieldsetBindings hydration', () => {
122
+ beforeEach(() => {
123
+ fetchCustomFieldFormStructureMock.mockReset()
124
+ buildFormFieldFromCustomFieldDefMock.mockReset()
125
+ triggerInjectionEventMock.mockClear()
126
+ configureTwoFieldsets()
127
+ })
128
+
129
+ it('activates the persisted fieldset when initialValues arrive after fieldset metadata loads', async () => {
130
+ // Mirrors the resources edit page: the record (and its persisted
131
+ // customFieldsetCode) loads asynchronously AFTER custom-field metadata has
132
+ // already resolved (gated behind resourceTypesLoaded), so the fieldset
133
+ // selector is hydrated only via the binding, never via a user interaction.
134
+ const { container, rerender } = renderWithProviders(
135
+ <DelayedHost initialValues={undefined} />,
136
+ { dict: { 'ui.forms.actions.save': 'Save' } },
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(fetchCustomFieldFormStructureMock).toHaveBeenCalled()
141
+ })
142
+
143
+ await act(async () => {
144
+ rerender(
145
+ <DelayedHost
146
+ initialValues={{ id: 'res-1', name: 'Engineering Laptop 1', customFieldsetCode: LAPTOP_CODE }}
147
+ />,
148
+ )
149
+ await Promise.resolve()
150
+ await Promise.resolve()
151
+ })
152
+
153
+ await waitFor(() => {
154
+ expect(container.querySelector('[data-crud-field-id="cf_serial_number"]')).not.toBeNull()
155
+ })
156
+ expect(container.querySelector('[data-crud-field-id="cf_asset_tag"]')).toBeNull()
157
+ })
158
+ })
@@ -14,6 +14,7 @@ import { Switch } from '../../primitives/switch'
14
14
  import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'
15
15
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../primitives/dialog'
16
16
  import { Tag } from '../../primitives/tag'
17
+ import { Badge } from '../../primitives/badge'
17
18
  import {
18
19
  Select,
19
20
  SelectContent,
@@ -31,12 +32,14 @@ import { useConfirmDialog } from '../confirm-dialog'
31
32
  import { useGuardedMutation } from '../injection/useGuardedMutation'
32
33
  import {
33
34
  applyCustomizationDraft,
35
+ applyGroupHidden,
34
36
  applyItemOrder,
35
37
  cloneSidebarGroups,
36
38
  collectSidebarDefaults,
37
39
  filterMainSidebarGroups,
38
40
  mergeGroupOrder,
39
41
  resolveGroupKey,
42
+ resolveGroupVisibility,
40
43
  resolveItemKey,
41
44
  type SidebarCustomizationDraft,
42
45
  type SidebarGroup,
@@ -548,6 +551,19 @@ export function SidebarCustomizationEditor({
548
551
  })
549
552
  }, [updateDraft])
550
553
 
554
+ /**
555
+ * Hiding a whole group previously meant toggling every item in it one at a time — a dozen-plus
556
+ * clicks for what reads as a single decision. Writes the same `hiddenItemIds` keys the per-item
557
+ * switches do, so the persisted settings are indistinguishable from reaching this state by hand.
558
+ */
559
+ const setGroupHidden = React.useCallback((groupKey: string, hidden: boolean) => {
560
+ updateDraft((draft) => {
561
+ const group = baseSnapshotRef.current?.find((candidate) => resolveGroupKey(candidate) === groupKey)
562
+ if (!group) return draft
563
+ return { ...draft, hiddenItemIds: applyGroupHidden(draft.hiddenItemIds, group, hidden) }
564
+ })
565
+ }, [updateDraft])
566
+
551
567
  const reset = React.useCallback(() => {
552
568
  if (!baseSnapshotRef.current) return
553
569
  if (selectedVariant) {
@@ -1222,7 +1238,30 @@ export function SidebarCustomizationEditor({
1222
1238
  </p>
1223
1239
  ) : null}
1224
1240
  </div>
1225
- <div className="flex shrink-0 items-center gap-1 mt-[26px]">
1241
+ <div className="flex shrink-0 items-center gap-2 mt-6">
1242
+ {(() => {
1243
+ const visibility = resolveGroupVisibility(baseGroup, draft.hiddenItemIds)
1244
+ const groupName = trimmedValue.length > 0 ? trimmedValue : placeholder
1245
+ const label = visibility === 'partial'
1246
+ ? t('appShell.sidebarCustomizationHideGroupPartial', 'Some items in {group} are hidden — turn off to hide the whole group', { group: groupName })
1247
+ : t('appShell.sidebarCustomizationShowGroup', 'Show {group}', { group: groupName })
1248
+ return (
1249
+ <>
1250
+ {visibility === 'hidden' ? (
1251
+ <Badge variant="muted" size="sm" className="uppercase tracking-wide">
1252
+ {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1253
+ </Badge>
1254
+ ) : null}
1255
+ <Switch
1256
+ checked={visibility !== 'hidden'}
1257
+ onCheckedChange={(next) => setGroupHidden(groupId, next !== true)}
1258
+ disabled={isBusy}
1259
+ aria-label={label}
1260
+ title={label}
1261
+ />
1262
+ </>
1263
+ )
1264
+ })()}
1226
1265
  <IconButton
1227
1266
  type="button"
1228
1267
  variant="outline"
@@ -1349,9 +1388,9 @@ function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth,
1349
1388
  </div>
1350
1389
  <div className="flex shrink-0 items-center gap-2 pt-1.5">
1351
1390
  {hidden ? (
1352
- <span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1391
+ <Badge variant="muted" size="sm" className="uppercase tracking-wide">
1353
1392
  {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1354
- </span>
1393
+ </Badge>
1355
1394
  ) : null}
1356
1395
  <Switch
1357
1396
  checked={!hidden}