@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +30 -0
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +10 -9
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/fetchAllPages.js +57 -0
- package/dist/backend/utils/fetchAllPages.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +10 -1
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
- package/src/backend/sidebar/customization-helpers.ts +47 -0
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
- package/src/backend/utils/customFieldForms.ts +15 -11
- package/src/backend/utils/fetchAllPages.ts +93 -0
|
@@ -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 {
|
|
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
|
|
38
|
-
if (!
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
5
|
-
"mappings": "AAKA;AAAA,EACE;AAAA,EAEA;AAAA,OACK;AACP,SAAS,eAAe,uCAAuC;AAC/D,SAAS,
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
-
<
|
|
1391
|
+
<Badge variant="muted" size="sm" className="uppercase tracking-wide">
|
|
1353
1392
|
{t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
|
|
1354
|
-
</
|
|
1393
|
+
</Badge>
|
|
1355
1394
|
) : null}
|
|
1356
1395
|
<Switch
|
|
1357
1396
|
checked={!hidden}
|