@open-mercato/ui 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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/dist/backend/DataTable.js +8 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +2 -4
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +20 -7
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +10 -0
- package/src/backend/crud/useGroupOrder.ts +12 -4
- package/src/backend/utils/customFieldDefs.ts +1 -0
|
@@ -29,10 +29,7 @@ function useGroupOrder(pageType, defaultGroupIds) {
|
|
|
29
29
|
const saved = readJsonFromLocalStorage(getStorageKey(pageType), null);
|
|
30
30
|
setSavedOrder(Array.isArray(saved) ? saved : null);
|
|
31
31
|
}, [pageType]);
|
|
32
|
-
const mergedIds =
|
|
33
|
-
() => savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds,
|
|
34
|
-
[defaultGroupIds, savedOrder]
|
|
35
|
-
);
|
|
32
|
+
const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds;
|
|
36
33
|
const stableIdsRef = React.useRef(mergedIds);
|
|
37
34
|
if (!arraysEqual(stableIdsRef.current, mergedIds)) {
|
|
38
35
|
stableIdsRef.current = mergedIds;
|
|
@@ -43,6 +40,7 @@ function useGroupOrder(pageType, defaultGroupIds) {
|
|
|
43
40
|
const next = [...stableIdsRef.current];
|
|
44
41
|
const [moved] = next.splice(fromIndex, 1);
|
|
45
42
|
next.splice(toIndex, 0, moved);
|
|
43
|
+
stableIdsRef.current = next;
|
|
46
44
|
writeJsonToLocalStorage(getStorageKey(pageType), next);
|
|
47
45
|
setSavedOrder(next);
|
|
48
46
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/crud/useGroupOrder.ts"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n *\n * State holds only the saved preference; the visible order is derived during\n * render. Syncing derived order back into state via an effect looped forever\n * when a host recreated `defaultGroupIds` with different content on every\n * render (#4386), so no effect writes state from `defaultGroupIds` here.\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)\n\n React.useEffect(() => {\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n setSavedOrder(Array.isArray(saved) ? saved : null)\n }, [pageType])\n\n const mergedIds =
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n *\n * State holds only the saved preference; the visible order is derived during\n * render. Syncing derived order back into state via an effect looped forever\n * when a host recreated `defaultGroupIds` with different content on every\n * render (#4386), so no effect writes state from `defaultGroupIds` here.\n *\n * `stableIdsRef` is mutated during render, which React's render-purity rule\n * normally forbids. It is safe here because the guard swaps the reference only\n * when the *content* differs: a concurrent render that React discards can leave\n * behind a different array identity, but never a content-stale value, and the\n * render that commits always leaves `stableIdsRef.current` content-equal to its\n * own `mergedIds`. `reorder` advances the same ref from its event handler so\n * that several reorders within one commit compose instead of overwriting each\n * other; a discarded state update leaves the ref ahead of `savedOrder` only\n * until the next render's content guard pulls it back (#4691).\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)\n\n React.useEffect(() => {\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n setSavedOrder(Array.isArray(saved) ? saved : null)\n }, [pageType])\n\n const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds\n\n const stableIdsRef = React.useRef(mergedIds)\n if (!arraysEqual(stableIdsRef.current, mergedIds)) {\n stableIdsRef.current = mergedIds\n }\n const orderedIds = stableIdsRef.current\n\n const reorder = React.useCallback(\n (fromIndex: number, toIndex: number) => {\n const next = [...stableIdsRef.current]\n const [moved] = next.splice(fromIndex, 1)\n next.splice(toIndex, 0, moved)\n stableIdsRef.current = next\n writeJsonToLocalStorage(getStorageKey(pageType), next)\n setSavedOrder(next)\n },\n [pageType],\n )\n\n return { orderedIds, reorder }\n}\n"],
|
|
5
|
+
"mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;AAqBO,SAAS,cAAc,UAAkB,iBAA2B;AACzE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAA0B,IAAI;AAExE,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,yBAA0C,cAAc,QAAQ,GAAG,IAAI;AACrF,kBAAc,MAAM,QAAQ,KAAK,IAAI,QAAQ,IAAI;AAAA,EACnD,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,YAAY,aAAa,WAAW,YAAY,eAAe,IAAI;AAEzE,QAAM,eAAe,MAAM,OAAO,SAAS;AAC3C,MAAI,CAAC,YAAY,aAAa,SAAS,SAAS,GAAG;AACjD,iBAAa,UAAU;AAAA,EACzB;AACA,QAAM,aAAa,aAAa;AAEhC,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,WAAmB,YAAoB;AACtC,YAAM,OAAO,CAAC,GAAG,aAAa,OAAO;AACrC,YAAM,CAAC,KAAK,IAAI,KAAK,OAAO,WAAW,CAAC;AACxC,WAAK,OAAO,SAAS,GAAG,KAAK;AAC7B,mBAAa,UAAU;AACvB,8BAAwB,cAAc,QAAQ,GAAG,IAAI;AACrD,oBAAc,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/customFieldDefs.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // phone-specific config\n defaultCountryIso2?: string\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
|
|
5
|
-
"mappings": "AAAA,YAAY,WAAW;AACvB,SAAS,gBAAuD;AAChE,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // phone-specific config\n defaultCountryIso2?: string\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n relatedEntityId?: string\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
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.6859.1.e4727b568a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
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.6859.1.e4727b568a",
|
|
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
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.7-develop.6859.1.e4727b568a",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1699,18 +1699,31 @@ export function DataTable<T>({
|
|
|
1699
1699
|
})
|
|
1700
1700
|
}, [table, mergedColumns])
|
|
1701
1701
|
|
|
1702
|
-
|
|
1702
|
+
// A stored perspective seeds `columnVisibility` at mount and wins outright over the
|
|
1703
|
+
// `meta.hidden` defaults, so the auto-hide pass is skipped entirely in that case.
|
|
1704
|
+
const visibilitySeededByStoredSettings = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
|
|
1705
|
+
// Auto-hiding is a per-column default, applied once per column — not an enforcement.
|
|
1706
|
+
// It cannot be latched by a single has-run boolean: columns arrive in waves, because
|
|
1707
|
+
// custom-field columns are built from definitions fetched asynchronously. The first
|
|
1708
|
+
// render carries no `cf_*` column at all, so a run-once pass found nothing to hide and
|
|
1709
|
+
// still burned the latch, leaving fields declared `listVisible: false` visible for the
|
|
1710
|
+
// rest of the session after a hard page load (#4859).
|
|
1711
|
+
// It also cannot lean on `columnVisibility` as the record of what has been decided:
|
|
1712
|
+
// `handleColumnChooserToggle` *deletes* a column's entry when the user turns it back on,
|
|
1713
|
+
// so a re-shown column is indistinguishable from one never seen and would be hidden again
|
|
1714
|
+
// on the next wave. Hence an explicit per-column record of what this pass has applied.
|
|
1715
|
+
const autoHiddenColumnIds = React.useRef<Set<string>>(new Set())
|
|
1703
1716
|
React.useEffect(() => {
|
|
1704
|
-
if (
|
|
1717
|
+
if (visibilitySeededByStoredSettings.current) return
|
|
1705
1718
|
const hidden: VisibilityState = {}
|
|
1706
1719
|
table.getAllLeafColumns().forEach((column) => {
|
|
1707
1720
|
const hiddenMeta = (column.columnDef as any)?.meta?.hidden
|
|
1708
|
-
if (hiddenMeta
|
|
1721
|
+
if (!hiddenMeta || autoHiddenColumnIds.current.has(column.id)) return
|
|
1722
|
+
hidden[column.id] = false
|
|
1723
|
+
autoHiddenColumnIds.current.add(column.id)
|
|
1709
1724
|
})
|
|
1710
|
-
if (Object.keys(hidden).length)
|
|
1711
|
-
|
|
1712
|
-
}
|
|
1713
|
-
initialVisibilityApplied.current = true
|
|
1725
|
+
if (!Object.keys(hidden).length) return
|
|
1726
|
+
setColumnVisibility((prev) => ({ ...hidden, ...prev }))
|
|
1714
1727
|
}, [table, mergedColumns])
|
|
1715
1728
|
|
|
1716
1729
|
const getCurrentSettings = React.useCallback((): PerspectiveSettings => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
6
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
7
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
8
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
+
import { DataTable } from '../DataTable'
|
|
10
|
+
|
|
11
|
+
jest.mock('next/navigation', () => ({
|
|
12
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
16
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
|
|
20
|
+
getInjectionRegistryVersion: () => 0,
|
|
21
|
+
subscribeToInjectionRegistryChanges: () => () => {},
|
|
22
|
+
loadInjectionWidgetsForSpot: jest.fn(async () => []),
|
|
23
|
+
loadInjectionDataWidgetsForSpot: jest.fn(async () => []),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
type Row = { id: string; name: string }
|
|
27
|
+
|
|
28
|
+
const ROWS: Row[] = [{ id: '1', name: 'Row one' }]
|
|
29
|
+
|
|
30
|
+
const BASE_COLUMNS: ColumnDef<Row>[] = [
|
|
31
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const COLUMNS_WITH_CUSTOM_FIELDS: ColumnDef<Row>[] = [
|
|
35
|
+
...BASE_COLUMNS,
|
|
36
|
+
{ accessorKey: 'cf_archived_note', header: 'Archived note', meta: { hidden: true } },
|
|
37
|
+
{ accessorKey: 'cf_active_note', header: 'Active note' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; initialSettings?: unknown }) {
|
|
41
|
+
const queryClient = React.useMemo(
|
|
42
|
+
() => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
|
|
43
|
+
[],
|
|
44
|
+
)
|
|
45
|
+
return (
|
|
46
|
+
<QueryClientProvider client={queryClient}>
|
|
47
|
+
<I18nProvider locale="en" dict={{}}>
|
|
48
|
+
<DataTable
|
|
49
|
+
columns={columns as never}
|
|
50
|
+
data={ROWS as never}
|
|
51
|
+
{...(initialSettings
|
|
52
|
+
? ({ perspective: { tableId: 'meta-hidden-late-columns', initialState: { initialSettings } } } as never)
|
|
53
|
+
: {})}
|
|
54
|
+
/>
|
|
55
|
+
</I18nProvider>
|
|
56
|
+
</QueryClientProvider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('DataTable — meta.hidden on columns that arrive after the first render', () => {
|
|
61
|
+
it('hides a meta.hidden column that only appears on a later render', async () => {
|
|
62
|
+
// The regression: the auto-hide pass used to be latched by a single boolean ref, so it
|
|
63
|
+
// ran on the first render — when no `cf_*` column existed yet — found nothing to hide,
|
|
64
|
+
// and never ran again. Custom fields declared `listVisible: false` stayed visible for
|
|
65
|
+
// the whole session after a hard page load, while a client-side navigation (definitions
|
|
66
|
+
// already in the query cache) rendered them correctly.
|
|
67
|
+
const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
|
|
68
|
+
await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
|
|
69
|
+
|
|
70
|
+
rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
|
|
71
|
+
|
|
72
|
+
await waitFor(() => expect(screen.queryByText('Archived note')).toBeNull())
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('leaves a late column without meta.hidden visible', async () => {
|
|
76
|
+
// Pins the other half of the contract, so the fix can never degrade into
|
|
77
|
+
// "columns that arrive late are dropped".
|
|
78
|
+
const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
|
|
79
|
+
await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
|
|
80
|
+
|
|
81
|
+
rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hides a meta.hidden column that is present from the very first render', async () => {
|
|
87
|
+
// The behaviour the original latch was written for, which must survive the fix.
|
|
88
|
+
render(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
|
|
89
|
+
|
|
90
|
+
await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
|
|
91
|
+
expect(screen.queryByText('Archived note')).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('lets a stored perspective override the meta.hidden defaults', async () => {
|
|
95
|
+
// A saved perspective seeds columnVisibility at mount and wins outright.
|
|
96
|
+
render(
|
|
97
|
+
<Harness
|
|
98
|
+
columns={COLUMNS_WITH_CUSTOM_FIELDS}
|
|
99
|
+
initialSettings={{ columnVisibility: { cf_archived_note: true } }}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
await waitFor(() => expect(screen.getByText('Archived note')).toBeTruthy())
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -37,6 +37,16 @@ describe('useGroupOrder', () => {
|
|
|
37
37
|
expect(result.current.orderedIds).toEqual(['a', 'd', 'b', 'c'])
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
it('composes two reorder() calls made within a single commit', () => {
|
|
41
|
+
const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
|
|
42
|
+
act(() => {
|
|
43
|
+
result.current.reorder(0, 2)
|
|
44
|
+
result.current.reorder(0, 1)
|
|
45
|
+
})
|
|
46
|
+
expect(result.current.orderedIds).toEqual(['c', 'b', 'a'])
|
|
47
|
+
expect(JSON.parse(localStorage.getItem('om:group-order:people')!)).toEqual(['c', 'b', 'a'])
|
|
48
|
+
})
|
|
49
|
+
|
|
40
50
|
it('updates ordering when defaults change to include a new id', () => {
|
|
41
51
|
const { result, rerender } = renderHook(
|
|
42
52
|
({ ids }) => useGroupOrder('p', ids),
|
|
@@ -36,6 +36,16 @@ function arraysEqual(a: string[], b: string[]): boolean {
|
|
|
36
36
|
* render. Syncing derived order back into state via an effect looped forever
|
|
37
37
|
* when a host recreated `defaultGroupIds` with different content on every
|
|
38
38
|
* render (#4386), so no effect writes state from `defaultGroupIds` here.
|
|
39
|
+
*
|
|
40
|
+
* `stableIdsRef` is mutated during render, which React's render-purity rule
|
|
41
|
+
* normally forbids. It is safe here because the guard swaps the reference only
|
|
42
|
+
* when the *content* differs: a concurrent render that React discards can leave
|
|
43
|
+
* behind a different array identity, but never a content-stale value, and the
|
|
44
|
+
* render that commits always leaves `stableIdsRef.current` content-equal to its
|
|
45
|
+
* own `mergedIds`. `reorder` advances the same ref from its event handler so
|
|
46
|
+
* that several reorders within one commit compose instead of overwriting each
|
|
47
|
+
* other; a discarded state update leaves the ref ahead of `savedOrder` only
|
|
48
|
+
* until the next render's content guard pulls it back (#4691).
|
|
39
49
|
*/
|
|
40
50
|
export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
|
|
41
51
|
const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)
|
|
@@ -45,10 +55,7 @@ export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
|
|
|
45
55
|
setSavedOrder(Array.isArray(saved) ? saved : null)
|
|
46
56
|
}, [pageType])
|
|
47
57
|
|
|
48
|
-
const mergedIds =
|
|
49
|
-
() => (savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds),
|
|
50
|
-
[defaultGroupIds, savedOrder],
|
|
51
|
-
)
|
|
58
|
+
const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds
|
|
52
59
|
|
|
53
60
|
const stableIdsRef = React.useRef(mergedIds)
|
|
54
61
|
if (!arraysEqual(stableIdsRef.current, mergedIds)) {
|
|
@@ -61,6 +68,7 @@ export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
|
|
|
61
68
|
const next = [...stableIdsRef.current]
|
|
62
69
|
const [moved] = next.splice(fromIndex, 1)
|
|
63
70
|
next.splice(toIndex, 0, moved)
|
|
71
|
+
stableIdsRef.current = next
|
|
64
72
|
writeJsonToLocalStorage(getStorageKey(pageType), next)
|
|
65
73
|
setSavedOrder(next)
|
|
66
74
|
},
|