@open-mercato/ui 0.8.1-develop.7261.1.5d42353f61 → 0.8.1-develop.7263.1.bbc6db3437
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/notifications/NotificationDispatcher.js +3 -1
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/localizeNotification.js +11 -0
- package/dist/backend/notifications/localizeNotification.js.map +7 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js +5 -4
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/notifications/NotificationDispatcher.ts +3 -1
- package/src/backend/notifications/__tests__/NotificationDispatcher.localize.test.tsx +118 -0
- package/src/backend/notifications/localizeNotification.ts +35 -0
- package/src/backend/utils/__tests__/useAutoDiscoveredFields.injectedColumns.test.tsx +76 -0
- package/src/backend/utils/useAutoDiscoveredFields.ts +8 -4
package/.turbo/turbo-build.log
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from "@open-mercato/shared/lib/notifications/handler-registry";
|
|
5
5
|
import { hasAllFeatures } from "@open-mercato/shared/security/features";
|
|
6
6
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
7
|
+
import { localizeNotificationCopy } from "./localizeNotification.js";
|
|
7
8
|
const logger = createLogger("ui").child({ component: "notifications" });
|
|
8
9
|
const DEFAULT_PRIORITY = 50;
|
|
9
10
|
const MAX_TRACKED_IDS = 500;
|
|
@@ -72,6 +73,7 @@ class NotificationDispatcher {
|
|
|
72
73
|
if (this.handledIds.has(notification.id)) continue;
|
|
73
74
|
let handlerRan = false;
|
|
74
75
|
let hadFeatureBlockedHandler = false;
|
|
76
|
+
const localized = localizeNotificationCopy(notification, runtime.t);
|
|
75
77
|
for (const entry of sortedEntries) {
|
|
76
78
|
const handler = entry.handler;
|
|
77
79
|
if (!matchesType(handler.notificationType, notification.type)) continue;
|
|
@@ -82,7 +84,7 @@ class NotificationDispatcher {
|
|
|
82
84
|
if (this.shouldDebounce(handler, notification.id)) continue;
|
|
83
85
|
handlerRan = true;
|
|
84
86
|
try {
|
|
85
|
-
void Promise.resolve(handler.handle(
|
|
87
|
+
void Promise.resolve(handler.handle(localized, context));
|
|
86
88
|
} catch (error) {
|
|
87
89
|
logger.error("Notification handler failed", { handlerId: handler.id, err: error });
|
|
88
90
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/NotificationDispatcher.ts"],
|
|
4
|
-
"sourcesContent": ["import { flash, type FlashKind } from '@open-mercato/ui/backend/FlashMessages'\nimport {\n getNotificationHandlerEntries,\n} from '@open-mercato/shared/lib/notifications/handler-registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport type {\n NotificationHandler,\n NotificationHandlerContext,\n NotificationHandlerPopupOptions,\n NotificationHandlerToastOptions,\n} from '@open-mercato/shared/modules/notifications/handler'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'notifications' })\n\ntype RuntimeContext = {\n userId?: string\n features: string[]\n currentPath: string\n t?: (key: string, fallback?: string) => string\n refreshNotifications: () => void\n navigate: (href: string) => void\n markAsRead: (notificationId: string) => Promise<void>\n dismiss: (notificationId: string) => Promise<void>\n}\n\ntype NotificationEffectListener = {\n id: number\n pattern: string | string[]\n effect: (notification: NotificationDto) => void\n}\n\nconst DEFAULT_PRIORITY = 50\nconst MAX_TRACKED_IDS = 500\nconst RETAINED_IDS_AFTER_PRUNE = 200\nconst POPUP_EVENT_NAME = 'om:notifications:popup'\n\nfunction toFlashKind(value: NotificationHandlerToastOptions['severity'] | NotificationHandlerPopupOptions['severity']): FlashKind {\n if (value === 'success') return 'success'\n if (value === 'warning') return 'warning'\n if (value === 'error') return 'error'\n return 'info'\n}\n\nfunction matchesType(pattern: string | string[], type: string): boolean {\n const patterns = Array.isArray(pattern) ? pattern : [pattern]\n return patterns.some((current) => {\n if (current === '*') return true\n if (current.endsWith('.*')) return type.startsWith(current.slice(0, -1))\n return current === type\n })\n}\n\nfunction matchesFeatures(required: string[] | undefined, current: string[]): boolean {\n return hasAllFeatures(current, required)\n}\n\nfunction buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContext {\n return {\n userId: runtime.userId,\n features: runtime.features,\n currentPath: runtime.currentPath,\n t: runtime.t,\n toast(options) {\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n popup(options) {\n if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {\n window.dispatchEvent(new CustomEvent(POPUP_EVENT_NAME, { detail: options }))\n }\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n emitEvent(eventName, detail) {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n window.dispatchEvent(new CustomEvent(eventName, { detail }))\n },\n refreshNotifications: runtime.refreshNotifications,\n navigate(href) {\n runtime.navigate(href)\n },\n markAsRead: runtime.markAsRead,\n dismiss: runtime.dismiss,\n }\n}\n\nclass NotificationDispatcher {\n private handledIds = new Set<string>()\n private handledOrder: string[] = []\n private listeners = new Map<number, NotificationEffectListener>()\n private listenerIdSeq = 1\n private debounceByHandler = new Map<string, number>()\n\n dispatch(notifications: NotificationDto[], runtime: RuntimeContext) {\n const entries = getNotificationHandlerEntries()\n if (notifications.length === 0 && this.listeners.size === 0) return\n const context = buildHandlerContext(runtime)\n const sortedEntries = entries\n .slice()\n .sort((a, b) => (b.handler.priority ?? DEFAULT_PRIORITY) - (a.handler.priority ?? DEFAULT_PRIORITY))\n\n for (const notification of notifications) {\n if (this.handledIds.has(notification.id)) continue\n\n let handlerRan = false\n let hadFeatureBlockedHandler = false\n\n for (const entry of sortedEntries) {\n const handler = entry.handler\n if (!matchesType(handler.notificationType, notification.type)) continue\n if (!matchesFeatures(handler.features, runtime.features)) {\n hadFeatureBlockedHandler = true\n continue\n }\n if (this.shouldDebounce(handler, notification.id)) continue\n handlerRan = true\n try {\n void Promise.resolve(handler.handle(
|
|
5
|
-
"mappings": "AAAA,SAAS,aAA6B;AACtC;AAAA,EACE;AAAA,OACK;AACP,SAAS,sBAAsB;AAQ/B,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { flash, type FlashKind } from '@open-mercato/ui/backend/FlashMessages'\nimport {\n getNotificationHandlerEntries,\n} from '@open-mercato/shared/lib/notifications/handler-registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport type {\n NotificationHandler,\n NotificationHandlerContext,\n NotificationHandlerPopupOptions,\n NotificationHandlerToastOptions,\n} from '@open-mercato/shared/modules/notifications/handler'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { localizeNotificationCopy } from './localizeNotification'\n\nconst logger = createLogger('ui').child({ component: 'notifications' })\n\ntype RuntimeContext = {\n userId?: string\n features: string[]\n currentPath: string\n t?: (key: string, fallback?: string) => string\n refreshNotifications: () => void\n navigate: (href: string) => void\n markAsRead: (notificationId: string) => Promise<void>\n dismiss: (notificationId: string) => Promise<void>\n}\n\ntype NotificationEffectListener = {\n id: number\n pattern: string | string[]\n effect: (notification: NotificationDto) => void\n}\n\nconst DEFAULT_PRIORITY = 50\nconst MAX_TRACKED_IDS = 500\nconst RETAINED_IDS_AFTER_PRUNE = 200\nconst POPUP_EVENT_NAME = 'om:notifications:popup'\n\nfunction toFlashKind(value: NotificationHandlerToastOptions['severity'] | NotificationHandlerPopupOptions['severity']): FlashKind {\n if (value === 'success') return 'success'\n if (value === 'warning') return 'warning'\n if (value === 'error') return 'error'\n return 'info'\n}\n\nfunction matchesType(pattern: string | string[], type: string): boolean {\n const patterns = Array.isArray(pattern) ? pattern : [pattern]\n return patterns.some((current) => {\n if (current === '*') return true\n if (current.endsWith('.*')) return type.startsWith(current.slice(0, -1))\n return current === type\n })\n}\n\nfunction matchesFeatures(required: string[] | undefined, current: string[]): boolean {\n return hasAllFeatures(current, required)\n}\n\nfunction buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContext {\n return {\n userId: runtime.userId,\n features: runtime.features,\n currentPath: runtime.currentPath,\n t: runtime.t,\n toast(options) {\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n popup(options) {\n if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {\n window.dispatchEvent(new CustomEvent(POPUP_EVENT_NAME, { detail: options }))\n }\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n emitEvent(eventName, detail) {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n window.dispatchEvent(new CustomEvent(eventName, { detail }))\n },\n refreshNotifications: runtime.refreshNotifications,\n navigate(href) {\n runtime.navigate(href)\n },\n markAsRead: runtime.markAsRead,\n dismiss: runtime.dismiss,\n }\n}\n\nclass NotificationDispatcher {\n private handledIds = new Set<string>()\n private handledOrder: string[] = []\n private listeners = new Map<number, NotificationEffectListener>()\n private listenerIdSeq = 1\n private debounceByHandler = new Map<string, number>()\n\n dispatch(notifications: NotificationDto[], runtime: RuntimeContext) {\n const entries = getNotificationHandlerEntries()\n if (notifications.length === 0 && this.listeners.size === 0) return\n const context = buildHandlerContext(runtime)\n const sortedEntries = entries\n .slice()\n .sort((a, b) => (b.handler.priority ?? DEFAULT_PRIORITY) - (a.handler.priority ?? DEFAULT_PRIORITY))\n\n for (const notification of notifications) {\n if (this.handledIds.has(notification.id)) continue\n\n let handlerRan = false\n let hadFeatureBlockedHandler = false\n const localized = localizeNotificationCopy(notification, runtime.t)\n\n for (const entry of sortedEntries) {\n const handler = entry.handler\n if (!matchesType(handler.notificationType, notification.type)) continue\n if (!matchesFeatures(handler.features, runtime.features)) {\n hadFeatureBlockedHandler = true\n continue\n }\n if (this.shouldDebounce(handler, notification.id)) continue\n handlerRan = true\n try {\n void Promise.resolve(handler.handle(localized, context))\n } catch (error) {\n logger.error('Notification handler failed', { handlerId: handler.id, err: error })\n }\n }\n\n for (const listener of this.listeners.values()) {\n if (!matchesType(listener.pattern, notification.type)) continue\n handlerRan = true\n try {\n listener.effect(notification)\n } catch (error) {\n logger.error('Notification effect listener failed', { err: error })\n }\n }\n\n // Only mark as permanently handled when something actually ran, or when no handler\n // was blocked by missing features. This prevents a race where the initial notification\n // fetch completes before the feature-check API resolves (features: []), causing all\n // feature-gated handlers to be skipped \u2014 but the notification gets permanently\n // deduped so it never fires when features are available on the next dispatch.\n if (handlerRan || !hadFeatureBlockedHandler) {\n this.markHandled(notification.id)\n }\n }\n }\n\n subscribe(pattern: string | string[], effect: (notification: NotificationDto) => void): () => void {\n const id = this.listenerIdSeq++\n this.listeners.set(id, { id, pattern, effect })\n return () => {\n this.listeners.delete(id)\n }\n }\n\n resetForTests() {\n this.handledIds.clear()\n this.handledOrder = []\n this.listeners.clear()\n this.listenerIdSeq = 1\n this.debounceByHandler.clear()\n }\n\n private shouldDebounce(handler: NotificationHandler, notificationId: string): boolean {\n if (!handler.debounceMs || handler.debounceMs <= 0) return false\n const key = `${handler.id}:${notificationId}`\n const now = Date.now()\n const previous = this.debounceByHandler.get(key)\n if (typeof previous === 'number' && now - previous < handler.debounceMs) {\n return true\n }\n this.debounceByHandler.set(key, now)\n return false\n }\n\n private markHandled(notificationId: string) {\n this.handledIds.add(notificationId)\n this.handledOrder.push(notificationId)\n if (this.handledOrder.length <= MAX_TRACKED_IDS) return\n const removeCount = this.handledOrder.length - RETAINED_IDS_AFTER_PRUNE\n const removed = this.handledOrder.splice(0, removeCount)\n for (const id of removed) {\n this.handledIds.delete(id)\n }\n }\n}\n\nconst dispatcher = new NotificationDispatcher()\n\nexport function dispatchNotificationHandlers(\n notifications: NotificationDto[],\n runtime: RuntimeContext,\n) {\n dispatcher.dispatch(notifications, runtime)\n}\n\nexport function subscribeNotificationEffects(\n pattern: string | string[],\n effect: (notification: NotificationDto) => void,\n): () => void {\n return dispatcher.subscribe(pattern, effect)\n}\n\nexport function getRequiredNotificationHandlerFeatures(): string[] {\n const required = new Set<string>()\n const entries = getNotificationHandlerEntries()\n for (const entry of entries) {\n for (const feature of entry.handler.features ?? []) {\n if (feature && feature.trim().length > 0) {\n required.add(feature)\n }\n }\n }\n return Array.from(required)\n}\n\nexport function __resetNotificationDispatcherForTests() {\n dispatcher.resetForTests()\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function localizeNotificationCopy(notification, t) {
|
|
2
|
+
if (!t) return notification;
|
|
3
|
+
const title = notification.titleKey ? t(notification.titleKey, notification.title, notification.titleVariables ?? void 0) : notification.title;
|
|
4
|
+
const body = notification.bodyKey ? t(notification.bodyKey, notification.body ?? notification.bodyKey, notification.bodyVariables ?? void 0) : notification.body;
|
|
5
|
+
if (title === notification.title && body === notification.body) return notification;
|
|
6
|
+
return { ...notification, title, body };
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
localizeNotificationCopy
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=localizeNotification.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/notifications/localizeNotification.ts"],
|
|
4
|
+
"sourcesContent": ["import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\n\nexport type NotificationTranslate = (\n key: string,\n fallback?: string,\n variables?: Record<string, string>,\n) => string\n\n/**\n * Give reactive handlers display copy instead of dictionary keys.\n *\n * `buildBaseNotificationFields` persists `title` / `body` as the type's\n * `titleKey` / `bodyKey`; the bell panel translates them through\n * `NotificationItem`, but a handler that forwards `notification.title` to\n * `context.toast(...)` would show the raw key. Resolving here, once per\n * dispatch, keeps every handler's toast/popup localized without each one\n * having to remember the translator (#6099).\n *\n * Without a translator (dispatch outside an `I18nProvider`) the DTO is\n * returned untouched, so the previous behaviour is the fallback.\n */\nexport function localizeNotificationCopy(\n notification: NotificationDto,\n t: NotificationTranslate | undefined,\n): NotificationDto {\n if (!t) return notification\n const title = notification.titleKey\n ? t(notification.titleKey, notification.title, notification.titleVariables ?? undefined)\n : notification.title\n const body = notification.bodyKey\n ? t(notification.bodyKey, notification.body ?? notification.bodyKey, notification.bodyVariables ?? undefined)\n : notification.body\n if (title === notification.title && body === notification.body) return notification\n return { ...notification, title, body }\n}\n"],
|
|
5
|
+
"mappings": "AAqBO,SAAS,yBACd,cACA,GACiB;AACjB,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,QAAQ,aAAa,WACvB,EAAE,aAAa,UAAU,aAAa,OAAO,aAAa,kBAAkB,MAAS,IACrF,aAAa;AACjB,QAAM,OAAO,aAAa,UACtB,EAAE,aAAa,SAAS,aAAa,QAAQ,aAAa,SAAS,aAAa,iBAAiB,MAAS,IAC1G,aAAa;AACjB,MAAI,UAAU,aAAa,SAAS,SAAS,aAAa,KAAM,QAAO;AACvE,SAAO,EAAE,GAAG,cAAc,OAAO,KAAK;AACxC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -35,7 +35,8 @@ function useAutoDiscoveredFields({
|
|
|
35
35
|
for (let i = 0; i < columns.length; i++) {
|
|
36
36
|
const col = columns[i];
|
|
37
37
|
const accessorKey = col.accessorKey;
|
|
38
|
-
|
|
38
|
+
const chooserKey = accessorKey ?? col.id;
|
|
39
|
+
if (!chooserKey) continue;
|
|
39
40
|
const meta = col.meta;
|
|
40
41
|
const label = resolveHeaderLabel(col);
|
|
41
42
|
if (!label) continue;
|
|
@@ -54,10 +55,10 @@ function useAutoDiscoveredFields({
|
|
|
54
55
|
if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions;
|
|
55
56
|
filterFields.push(field);
|
|
56
57
|
}
|
|
57
|
-
if (!seenChooserKeys.has(
|
|
58
|
-
seenChooserKeys.add(
|
|
58
|
+
if (!seenChooserKeys.has(chooserKey)) {
|
|
59
|
+
seenChooserKeys.add(chooserKey);
|
|
59
60
|
chooserFields.push({
|
|
60
|
-
key:
|
|
61
|
+
key: chooserKey,
|
|
61
62
|
label,
|
|
62
63
|
group: meta?.columnChooserGroup ?? defaultGroupLabel,
|
|
63
64
|
alwaysVisible: meta?.alwaysVisible ?? i === 0,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/useAutoDiscoveredFields.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { RowData } from '@tanstack/react-table'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useOptionalT } from '@open-mercato/shared/lib/i18n/context'\nimport type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'\nimport type { ColumnChooserField } from '../columns/ColumnChooserPanel'\nimport type { CustomFieldDefDto } from './customFieldDefs'\nimport {\n mapCustomFieldKindToFilterType,\n normalizeCustomFieldFilterOptions,\n supportsCustomFieldColumn,\n} from './customFieldColumns'\n\ntype ColumnMeta = {\n filterKey?: string\n filterType?: FilterFieldType\n filterOptions?: FilterOption[]\n filterLoadOptions?: (query?: string) => Promise<FilterOption[]>\n filterable?: boolean\n filterGroup?: string\n filterIconName?: string\n columnChooserGroup?: string\n alwaysVisible?: boolean\n hidden?: boolean\n truncate?: boolean\n maxWidth?: string\n priority?: number\n tooltipContent?: (row: unknown) => string | undefined\n}\n\nfunction resolveHeaderLabel<T extends RowData>(column: ColumnDef<T, unknown>): string {\n const header = (column as any).header\n if (typeof header === 'string') return header\n const accessorKey = (column as any).accessorKey as string | undefined\n if (!accessorKey) return ''\n return accessorKey\n .replace(/^cf_/, '')\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n}\n\nfunction inferFilterType(accessorKey: string, meta?: ColumnMeta): FilterFieldType {\n if (meta?.filterType) return meta.filterType\n if (accessorKey.endsWith('_at') || accessorKey === 'createdAt' || accessorKey === 'updatedAt' || accessorKey === 'expectedCloseAt' || accessorKey.endsWith('At')) return 'date'\n if (accessorKey === 'probability' || accessorKey === 'valueAmount' || accessorKey === 'value_amount') return 'number'\n if (meta?.filterOptions) return 'select'\n return 'text'\n}\n\nexport type UseAutoDiscoveredFieldsInput<T extends RowData = RowData> = {\n columns: ColumnDef<T, unknown>[]\n customFieldDefs: CustomFieldDefDto[]\n}\n\nexport type UseAutoDiscoveredFieldsResult = {\n advancedFilterFields: AdvancedFilterFieldDef[]\n columnChooserFields: ColumnChooserField[]\n}\n\nexport function useAutoDiscoveredFields<T extends RowData = RowData>({\n columns,\n customFieldDefs,\n}: UseAutoDiscoveredFieldsInput<T>): UseAutoDiscoveredFieldsResult {\n // Optional translator: this hook is a public entry point third-party modules\n // render, and it worked without an I18nProvider before it needed labels.\n const t = useOptionalT()\n const defaultGroupLabel = t?.('ui.columnChooser.defaultGroup', 'Columns') ?? 'Columns'\n const customFieldsGroupLabel = t?.('ui.columnChooser.customFieldsGroup', 'Custom Fields') ?? 'Custom Fields'\n return React.useMemo(() => {\n const filterFields: AdvancedFilterFieldDef[] = []\n const chooserFields: ColumnChooserField[] = []\n const seenFilterKeys = new Set<string>()\n const seenChooserKeys = new Set<string>()\n\n for (let i = 0; i < columns.length; i++) {\n const col = columns[i]\n const accessorKey = (col as any).accessorKey as string | undefined\n if (!
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AAGvB,SAAS,oBAAoB;AAI7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAmBP,SAAS,mBAAsC,QAAuC;AACpF,QAAM,SAAU,OAAe;AAC/B,MAAI,OAAO,WAAW,SAAU,QAAO;AACvC,QAAM,cAAe,OAAe;AACpC,MAAI,CAAC,YAAa,QAAO;AACzB,SAAO,YACJ,QAAQ,QAAQ,EAAE,EAClB,QAAQ,MAAM,GAAG,EACjB,QAAQ,SAAS,CAAC,MAAc,EAAE,YAAY,CAAC;AACpD;AAEA,SAAS,gBAAgB,aAAqB,MAAoC;AAChF,MAAI,MAAM,WAAY,QAAO,KAAK;AAClC,MAAI,YAAY,SAAS,KAAK,KAAK,gBAAgB,eAAe,gBAAgB,eAAe,gBAAgB,qBAAqB,YAAY,SAAS,IAAI,EAAG,QAAO;AACzK,MAAI,gBAAgB,iBAAiB,gBAAgB,iBAAiB,gBAAgB,eAAgB,QAAO;AAC7G,MAAI,MAAM,cAAe,QAAO;AAChC,SAAO;AACT;AAYO,SAAS,wBAAqD;AAAA,EACnE;AAAA,EACA;AACF,GAAmE;AAGjE,QAAM,IAAI,aAAa;AACvB,QAAM,oBAAoB,IAAI,iCAAiC,SAAS,KAAK;AAC7E,QAAM,yBAAyB,IAAI,sCAAsC,eAAe,KAAK;AAC7F,SAAO,MAAM,QAAQ,MAAM;AACzB,UAAM,eAAyC,CAAC;AAChD,UAAM,gBAAsC,CAAC;AAC7C,UAAM,iBAAiB,oBAAI,IAAY;AACvC,UAAM,kBAAkB,oBAAI,IAAY;AAExC,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACvC,YAAM,MAAM,QAAQ,CAAC;AACrB,YAAM,cAAe,IAAY;
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { RowData } from '@tanstack/react-table'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { useOptionalT } from '@open-mercato/shared/lib/i18n/context'\nimport type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'\nimport type { ColumnChooserField } from '../columns/ColumnChooserPanel'\nimport type { CustomFieldDefDto } from './customFieldDefs'\nimport {\n mapCustomFieldKindToFilterType,\n normalizeCustomFieldFilterOptions,\n supportsCustomFieldColumn,\n} from './customFieldColumns'\n\ntype ColumnMeta = {\n filterKey?: string\n filterType?: FilterFieldType\n filterOptions?: FilterOption[]\n filterLoadOptions?: (query?: string) => Promise<FilterOption[]>\n filterable?: boolean\n filterGroup?: string\n filterIconName?: string\n columnChooserGroup?: string\n alwaysVisible?: boolean\n hidden?: boolean\n truncate?: boolean\n maxWidth?: string\n priority?: number\n tooltipContent?: (row: unknown) => string | undefined\n}\n\nfunction resolveHeaderLabel<T extends RowData>(column: ColumnDef<T, unknown>): string {\n const header = (column as any).header\n if (typeof header === 'string') return header\n const accessorKey = (column as any).accessorKey as string | undefined\n if (!accessorKey) return ''\n return accessorKey\n .replace(/^cf_/, '')\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n}\n\nfunction inferFilterType(accessorKey: string, meta?: ColumnMeta): FilterFieldType {\n if (meta?.filterType) return meta.filterType\n if (accessorKey.endsWith('_at') || accessorKey === 'createdAt' || accessorKey === 'updatedAt' || accessorKey === 'expectedCloseAt' || accessorKey.endsWith('At')) return 'date'\n if (accessorKey === 'probability' || accessorKey === 'valueAmount' || accessorKey === 'value_amount') return 'number'\n if (meta?.filterOptions) return 'select'\n return 'text'\n}\n\nexport type UseAutoDiscoveredFieldsInput<T extends RowData = RowData> = {\n columns: ColumnDef<T, unknown>[]\n customFieldDefs: CustomFieldDefDto[]\n}\n\nexport type UseAutoDiscoveredFieldsResult = {\n advancedFilterFields: AdvancedFilterFieldDef[]\n columnChooserFields: ColumnChooserField[]\n}\n\nexport function useAutoDiscoveredFields<T extends RowData = RowData>({\n columns,\n customFieldDefs,\n}: UseAutoDiscoveredFieldsInput<T>): UseAutoDiscoveredFieldsResult {\n // Optional translator: this hook is a public entry point third-party modules\n // render, and it worked without an I18nProvider before it needed labels.\n const t = useOptionalT()\n const defaultGroupLabel = t?.('ui.columnChooser.defaultGroup', 'Columns') ?? 'Columns'\n const customFieldsGroupLabel = t?.('ui.columnChooser.customFieldsGroup', 'Custom Fields') ?? 'Custom Fields'\n return React.useMemo(() => {\n const filterFields: AdvancedFilterFieldDef[] = []\n const chooserFields: ColumnChooserField[] = []\n const seenFilterKeys = new Set<string>()\n const seenChooserKeys = new Set<string>()\n\n for (let i = 0; i < columns.length; i++) {\n const col = columns[i]\n const accessorKey = (col as any).accessorKey as string | undefined\n // Widget-injected columns carry `accessorFn` + `id` instead of `accessorKey`\n // (dotted access paths cannot be an object key), and the column id is what\n // the chooser toggle and visibility state already round-trip on.\n const chooserKey = accessorKey ?? ((col as any).id as string | undefined)\n if (!chooserKey) continue\n\n const meta = (col as any).meta as ColumnMeta | undefined\n const label = resolveHeaderLabel(col)\n if (!label) continue\n const filterKey = meta?.filterKey ?? accessorKey\n\n // Advanced filter field\n if (meta?.filterable !== false && filterKey && !seenFilterKeys.has(filterKey)) {\n seenFilterKeys.add(filterKey)\n const type = inferFilterType(filterKey, meta)\n const field: AdvancedFilterFieldDef = {\n key: filterKey,\n label,\n type,\n group: meta?.filterGroup ?? meta?.columnChooserGroup,\n }\n if (meta?.filterIconName) field.iconName = meta.filterIconName\n if (meta?.filterOptions) field.options = meta.filterOptions\n if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions\n filterFields.push(field)\n }\n\n // Column chooser field\n if (!seenChooserKeys.has(chooserKey)) {\n seenChooserKeys.add(chooserKey)\n chooserFields.push({\n key: chooserKey,\n label,\n group: meta?.columnChooserGroup ?? defaultGroupLabel,\n alwaysVisible: meta?.alwaysVisible ?? i === 0,\n defaultVisible: true,\n })\n }\n }\n\n // Append ALL custom field definitions (not just filterable/listVisible)\n // so that every entity field appears in both the column chooser and advanced filter\n for (const def of customFieldDefs) {\n if (!supportsCustomFieldColumn(def)) continue\n const filterKey = `cf_${def.key}`\n if (!seenFilterKeys.has(filterKey)) {\n seenFilterKeys.add(filterKey)\n const type = mapCustomFieldKindToFilterType(def.kind)\n const field: AdvancedFilterFieldDef = {\n key: filterKey,\n label: def.label || def.key,\n type,\n group: customFieldsGroupLabel,\n }\n if (type === 'select' && Array.isArray(def.options) && def.options.length) {\n field.options = normalizeCustomFieldFilterOptions(def.options)\n }\n filterFields.push(field)\n }\n if (!seenChooserKeys.has(filterKey)) {\n seenChooserKeys.add(filterKey)\n chooserFields.push({\n key: filterKey,\n label: def.label || def.key,\n group: def.group?.title ?? customFieldsGroupLabel,\n defaultVisible: false,\n })\n }\n }\n\n return { advancedFilterFields: filterFields, columnChooserFields: chooserFields }\n }, [columns, customFieldDefs, defaultGroupLabel, customFieldsGroupLabel])\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.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7263.1.bbc6db3437",
|
|
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.8.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.8.1-develop.7263.1.bbc6db3437",
|
|
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.8.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.8.1-develop.7263.1.bbc6db3437",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from '@open-mercato/shared/modules/notifications/handler'
|
|
12
12
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
13
13
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
14
|
+
import { localizeNotificationCopy } from './localizeNotification'
|
|
14
15
|
|
|
15
16
|
const logger = createLogger('ui').child({ component: 'notifications' })
|
|
16
17
|
|
|
@@ -106,6 +107,7 @@ class NotificationDispatcher {
|
|
|
106
107
|
|
|
107
108
|
let handlerRan = false
|
|
108
109
|
let hadFeatureBlockedHandler = false
|
|
110
|
+
const localized = localizeNotificationCopy(notification, runtime.t)
|
|
109
111
|
|
|
110
112
|
for (const entry of sortedEntries) {
|
|
111
113
|
const handler = entry.handler
|
|
@@ -117,7 +119,7 @@ class NotificationDispatcher {
|
|
|
117
119
|
if (this.shouldDebounce(handler, notification.id)) continue
|
|
118
120
|
handlerRan = true
|
|
119
121
|
try {
|
|
120
|
-
void Promise.resolve(handler.handle(
|
|
122
|
+
void Promise.resolve(handler.handle(localized, context))
|
|
121
123
|
} catch (error) {
|
|
122
124
|
logger.error('Notification handler failed', { handlerId: handler.id, err: error })
|
|
123
125
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
import { registerNotificationHandlers } from '@open-mercato/shared/lib/notifications/handler-registry'
|
|
3
|
+
import {
|
|
4
|
+
__resetNotificationDispatcherForTests,
|
|
5
|
+
dispatchNotificationHandlers,
|
|
6
|
+
subscribeNotificationEffects,
|
|
7
|
+
} from '../NotificationDispatcher'
|
|
8
|
+
|
|
9
|
+
const flash = jest.fn()
|
|
10
|
+
jest.mock('@open-mercato/ui/backend/FlashMessages', () => ({
|
|
11
|
+
flash: (...args: unknown[]) => flash(...args),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const TITLE_KEY = 'communication_channels.notifications.channel_requires_reauth.title'
|
|
15
|
+
const BODY_KEY = 'communication_channels.notifications.channel_requires_reauth.body'
|
|
16
|
+
|
|
17
|
+
const dictionary: Record<string, string> = {
|
|
18
|
+
[TITLE_KEY]: 'Channel needs reconnection',
|
|
19
|
+
[BODY_KEY]: 'Authentication expired. Reconnect {{channel}} to resume sending and receiving messages.',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function translate(key: string, fallback?: string, variables?: Record<string, string>): string {
|
|
23
|
+
const template = dictionary[key] ?? fallback ?? key
|
|
24
|
+
return Object.entries(variables ?? {}).reduce(
|
|
25
|
+
(text, [name, value]) => text.replaceAll(`{{${name}}}`, value),
|
|
26
|
+
template,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeNotification(overrides: Partial<NotificationDto> = {}): NotificationDto {
|
|
31
|
+
return {
|
|
32
|
+
id: 'n1',
|
|
33
|
+
type: 'communication_channels.channel.requires_reauth',
|
|
34
|
+
title: TITLE_KEY,
|
|
35
|
+
body: BODY_KEY,
|
|
36
|
+
titleKey: TITLE_KEY,
|
|
37
|
+
bodyKey: BODY_KEY,
|
|
38
|
+
bodyVariables: { channel: 'Sales inbox' },
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
status: 'unread',
|
|
41
|
+
actions: [],
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
...overrides,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runtime(t?: typeof translate) {
|
|
48
|
+
return {
|
|
49
|
+
features: [],
|
|
50
|
+
currentPath: '/backend',
|
|
51
|
+
t,
|
|
52
|
+
refreshNotifications: jest.fn(),
|
|
53
|
+
navigate: jest.fn(),
|
|
54
|
+
markAsRead: jest.fn(async () => {}),
|
|
55
|
+
dismiss: jest.fn(async () => {}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('NotificationDispatcher — handler copy localization (#6099)', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
__resetNotificationDispatcherForTests()
|
|
62
|
+
flash.mockReset()
|
|
63
|
+
registerNotificationHandlers([
|
|
64
|
+
{
|
|
65
|
+
moduleId: 'test',
|
|
66
|
+
handlers: [
|
|
67
|
+
{
|
|
68
|
+
id: 'test.toast',
|
|
69
|
+
notificationType: 'communication_channels.channel.requires_reauth',
|
|
70
|
+
handle(notification, context) {
|
|
71
|
+
context.toast({ title: notification.title, body: notification.body ?? undefined, severity: 'warning' })
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
registerNotificationHandlers([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hands handlers the translated title and body (with variables) instead of the dictionary keys', () => {
|
|
84
|
+
dispatchNotificationHandlers([makeNotification()], runtime(translate))
|
|
85
|
+
|
|
86
|
+
expect(flash).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(flash).toHaveBeenCalledWith(
|
|
88
|
+
'Channel needs reconnection: Authentication expired. Reconnect Sales inbox to resume sending and receiving messages.',
|
|
89
|
+
'warning',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('leaves a notification without keys untouched', () => {
|
|
94
|
+
dispatchNotificationHandlers(
|
|
95
|
+
[makeNotification({ title: 'Plain title', body: 'Plain body', titleKey: null, bodyKey: null, bodyVariables: null })],
|
|
96
|
+
runtime(translate),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(flash).toHaveBeenCalledWith('Plain title: Plain body', 'warning')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to the stored copy when no translator is available', () => {
|
|
103
|
+
dispatchNotificationHandlers([makeNotification()], runtime(undefined))
|
|
104
|
+
|
|
105
|
+
expect(flash).toHaveBeenCalledWith(`${TITLE_KEY}: ${BODY_KEY}`, 'warning')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does not rewrite the DTO seen by effect subscribers', () => {
|
|
109
|
+
const seen: NotificationDto[] = []
|
|
110
|
+
const unsubscribe = subscribeNotificationEffects('communication_channels.*', (item) => seen.push(item))
|
|
111
|
+
|
|
112
|
+
dispatchNotificationHandlers([makeNotification({ id: 'n2' })], runtime(translate))
|
|
113
|
+
unsubscribe()
|
|
114
|
+
|
|
115
|
+
expect(seen).toHaveLength(1)
|
|
116
|
+
expect(seen[0].title).toBe(TITLE_KEY)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
|
|
3
|
+
export type NotificationTranslate = (
|
|
4
|
+
key: string,
|
|
5
|
+
fallback?: string,
|
|
6
|
+
variables?: Record<string, string>,
|
|
7
|
+
) => string
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Give reactive handlers display copy instead of dictionary keys.
|
|
11
|
+
*
|
|
12
|
+
* `buildBaseNotificationFields` persists `title` / `body` as the type's
|
|
13
|
+
* `titleKey` / `bodyKey`; the bell panel translates them through
|
|
14
|
+
* `NotificationItem`, but a handler that forwards `notification.title` to
|
|
15
|
+
* `context.toast(...)` would show the raw key. Resolving here, once per
|
|
16
|
+
* dispatch, keeps every handler's toast/popup localized without each one
|
|
17
|
+
* having to remember the translator (#6099).
|
|
18
|
+
*
|
|
19
|
+
* Without a translator (dispatch outside an `I18nProvider`) the DTO is
|
|
20
|
+
* returned untouched, so the previous behaviour is the fallback.
|
|
21
|
+
*/
|
|
22
|
+
export function localizeNotificationCopy(
|
|
23
|
+
notification: NotificationDto,
|
|
24
|
+
t: NotificationTranslate | undefined,
|
|
25
|
+
): NotificationDto {
|
|
26
|
+
if (!t) return notification
|
|
27
|
+
const title = notification.titleKey
|
|
28
|
+
? t(notification.titleKey, notification.title, notification.titleVariables ?? undefined)
|
|
29
|
+
: notification.title
|
|
30
|
+
const body = notification.bodyKey
|
|
31
|
+
? t(notification.bodyKey, notification.body ?? notification.bodyKey, notification.bodyVariables ?? undefined)
|
|
32
|
+
: notification.body
|
|
33
|
+
if (title === notification.title && body === notification.body) return notification
|
|
34
|
+
return { ...notification, title, body }
|
|
35
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { renderHook } from '@testing-library/react'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
|
+
import { useAutoDiscoveredFields } from '../useAutoDiscoveredFields'
|
|
7
|
+
import type { CustomFieldDefDto } from '../customFieldDefs'
|
|
8
|
+
|
|
9
|
+
type Row = { id: string; name: string; person?: { company?: { name?: string } } }
|
|
10
|
+
|
|
11
|
+
const NO_CUSTOM_FIELDS = [] as unknown as CustomFieldDefDto[]
|
|
12
|
+
|
|
13
|
+
function renderFields(columns: ColumnDef<Row>[]) {
|
|
14
|
+
return renderHook(
|
|
15
|
+
() => useAutoDiscoveredFields<Row>({ columns, customFieldDefs: NO_CUSTOM_FIELDS }),
|
|
16
|
+
).result.current
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('useAutoDiscoveredFields with widget-injected columns', () => {
|
|
20
|
+
// DataTable builds columns from the `data-table:<tableId>:columns` injection spot
|
|
21
|
+
// as `accessorFn` + `id` so dotted access paths stay expressible.
|
|
22
|
+
const injectedColumn: ColumnDef<Row> = {
|
|
23
|
+
id: 'person.company.name',
|
|
24
|
+
accessorFn: (row: Row) => row.person?.company?.name,
|
|
25
|
+
header: 'Company',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('lists an injected column in the column chooser keyed by its column id', () => {
|
|
29
|
+
const { columnChooserFields } = renderFields([
|
|
30
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
31
|
+
injectedColumn,
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
expect(columnChooserFields.map((field) => field.key)).toEqual(['name', 'person.company.name'])
|
|
35
|
+
const injectedChooserField = columnChooserFields.find((field) => field.key === 'person.company.name')
|
|
36
|
+
expect(injectedChooserField?.label).toBe('Company')
|
|
37
|
+
expect(injectedChooserField?.alwaysVisible).toBe(false)
|
|
38
|
+
expect(injectedChooserField?.defaultVisible).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps an injected column out of the auto-discovered filter fields', () => {
|
|
42
|
+
const { advancedFilterFields } = renderFields([
|
|
43
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
44
|
+
injectedColumn,
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
expect(advancedFilterFields.map((field) => field.key)).toEqual(['name'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('still derives a filter field when an injected column declares an explicit filterKey', () => {
|
|
51
|
+
const { advancedFilterFields, columnChooserFields } = renderFields([
|
|
52
|
+
{ ...injectedColumn, meta: { filterKey: 'person_company_name' } } as ColumnDef<Row>,
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
expect(advancedFilterFields.map((field) => field.key)).toEqual(['person_company_name'])
|
|
56
|
+
expect(columnChooserFields.map((field) => field.key)).toEqual(['person.company.name'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('skips a column that has neither an accessorKey nor an id', () => {
|
|
60
|
+
const { columnChooserFields, advancedFilterFields } = renderFields([
|
|
61
|
+
{ header: 'Orphan' } as ColumnDef<Row>,
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
expect(columnChooserFields).toEqual([])
|
|
65
|
+
expect(advancedFilterFields).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('leaves ordinary accessorKey columns keyed by their accessorKey', () => {
|
|
69
|
+
const { columnChooserFields, advancedFilterFields } = renderFields([
|
|
70
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
expect(columnChooserFields.map((field) => field.key)).toEqual(['name'])
|
|
74
|
+
expect(advancedFilterFields.map((field) => field.key)).toEqual(['name'])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -76,7 +76,11 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
76
76
|
for (let i = 0; i < columns.length; i++) {
|
|
77
77
|
const col = columns[i]
|
|
78
78
|
const accessorKey = (col as any).accessorKey as string | undefined
|
|
79
|
-
|
|
79
|
+
// Widget-injected columns carry `accessorFn` + `id` instead of `accessorKey`
|
|
80
|
+
// (dotted access paths cannot be an object key), and the column id is what
|
|
81
|
+
// the chooser toggle and visibility state already round-trip on.
|
|
82
|
+
const chooserKey = accessorKey ?? ((col as any).id as string | undefined)
|
|
83
|
+
if (!chooserKey) continue
|
|
80
84
|
|
|
81
85
|
const meta = (col as any).meta as ColumnMeta | undefined
|
|
82
86
|
const label = resolveHeaderLabel(col)
|
|
@@ -100,10 +104,10 @@ export function useAutoDiscoveredFields<T extends RowData = RowData>({
|
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// Column chooser field
|
|
103
|
-
if (!seenChooserKeys.has(
|
|
104
|
-
seenChooserKeys.add(
|
|
107
|
+
if (!seenChooserKeys.has(chooserKey)) {
|
|
108
|
+
seenChooserKeys.add(chooserKey)
|
|
105
109
|
chooserFields.push({
|
|
106
|
-
key:
|
|
110
|
+
key: chooserKey,
|
|
107
111
|
label,
|
|
108
112
|
group: meta?.columnChooserGroup ?? defaultGroupLabel,
|
|
109
113
|
alwaysVisible: meta?.alwaysVisible ?? i === 0,
|