@open-mercato/ui 0.8.1-develop.7261.1.5d42353f61 → 0.8.1-develop.7266.1.8e520bbe03
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 +63 -16
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -1
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- 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/CrudForm.tsx +126 -18
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +428 -0
- package/src/backend/messages/__tests__/useMessageComposeOperations.test.tsx +75 -0
- package/src/backend/messages/useMessageComposeOperations.ts +1 -1
- 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
|
@@ -52,7 +52,7 @@ function buildComposePayload(params, input) {
|
|
|
52
52
|
sourceEntityId: sourceEntityId ?? void 0,
|
|
53
53
|
objects: contextObjects,
|
|
54
54
|
attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : void 0,
|
|
55
|
-
sendViaEmail:
|
|
55
|
+
sendViaEmail: params.sendViaEmail,
|
|
56
56
|
isDraft: input.isDraft
|
|
57
57
|
};
|
|
58
58
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/messages/useMessageComposeOperations.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n MessageComposerContextObject,\n MessageComposerProps,\n MessageTypeItem,\n} from './message-composer.types'\nimport type { MessagePriority } from './message-priority'\n\ntype Translator = ReturnType<typeof useT>\n\ntype MessageRecipient = {\n userId: string\n type: 'to'\n}\n\ntype SubmitRequest = {\n endpoint: string\n method: 'POST' | 'PATCH'\n payload: Record<string, unknown>\n}\n\ntype BaseOperation = {\n validate: () => string | null\n buildRequest: (input: { attachmentIds: string[] }) => SubmitRequest\n successMessage: string\n requiresAttachmentRefresh: boolean\n}\n\ntype ComposeOperationParams = {\n t: Translator\n messageType: string\n createableMessageTypes: MessageTypeItem[]\n priority: MessagePriority\n visibility: 'public' | 'internal'\n externalEmail: string\n recipientIds: string[]\n subject: string\n body: string\n bodyFormat: 'text' | 'markdown'\n sendViaEmail: boolean\n contextObject: MessageComposerContextObject | null\n defaultValues: MessageComposerProps['defaultValues']\n contextActionOptions: Array<{ id: string; label: string }>\n normalizedRequiredActionMode: 'none' | 'optional' | 'required'\n shouldShowContextActions: boolean\n contextActionRequired: boolean\n contextActionType: string\n}\n\ntype ReplyOperationParams = {\n t: Translator\n messageId?: string\n body: string\n bodyFormat: 'text' | 'markdown'\n replyAll: boolean\n recipientIds: string[]\n sendViaEmail: boolean\n}\n\ntype ForwardOperationParams = {\n t: Translator\n messageId?: string\n recipientIds: string[]\n body: string\n includeAttachments: boolean\n sendViaEmail: boolean\n}\n\ntype DraftOperationParams = Omit<ComposeOperationParams, 'createableMessageTypes'>\n & { messageId?: string }\n\nfunction isValidEmailAddress(value: string): boolean {\n const email = value.trim()\n if (!email || email.length > 254) return false\n\n const atIndex = email.indexOf('@')\n if (atIndex <= 0 || atIndex !== email.lastIndexOf('@') || atIndex === email.length - 1) return false\n\n for (const char of email) {\n if (char === ' ' || char === '\\t' || char === '\\n' || char === '\\r') return false\n }\n\n const localPart = email.slice(0, atIndex)\n const domainPart = email.slice(atIndex + 1)\n if (!localPart || !domainPart || localPart.length > 64) return false\n if (domainPart.length > 253 || !domainPart.includes('.')) return false\n if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) return false\n\n const domainLabels = domainPart.split('.')\n for (const label of domainLabels) {\n if (!label) return false\n if (label.startsWith('-') || label.endsWith('-')) return false\n }\n\n return true\n}\n\nfunction mapRecipients(recipientIds: string[]): MessageRecipient[] {\n return recipientIds.map((userId) => ({ userId, type: 'to' }))\n}\n\nfunction buildComposePayload(\n params: ComposeOperationParams | DraftOperationParams,\n input: { attachmentIds: string[]; isDraft: boolean },\n): Record<string, unknown> {\n const publicMessage = params.visibility === 'public'\n const sourceEntityType = params.contextObject?.sourceEntityType ?? params.defaultValues?.sourceEntityType\n const sourceEntityId = params.contextObject?.sourceEntityId ?? params.defaultValues?.sourceEntityId\n const selectedContextActionOption = params.contextActionOptions.find((option) => option.id === params.contextActionType)\n const resolvedContextActionRequired = params.shouldShowContextActions\n ? (params.normalizedRequiredActionMode === 'required' ? true : params.contextActionRequired)\n : (params.contextObject?.actionRequired ?? false)\n const resolvedContextActionType = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? params.contextActionType : params.contextObject?.actionType)\n : undefined\n const resolvedContextActionLabel = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? selectedContextActionOption?.label : params.contextObject?.actionLabel)\n : undefined\n const contextObjects = params.contextObject\n ? [{\n entityModule: params.contextObject.entityModule,\n entityType: params.contextObject.entityType,\n entityId: params.contextObject.entityId,\n actionRequired: resolvedContextActionRequired,\n actionType: resolvedContextActionType,\n actionLabel: resolvedContextActionLabel,\n }]\n : undefined\n\n return {\n type: params.messageType,\n priority: params.priority,\n visibility: params.visibility,\n externalEmail: publicMessage ? params.externalEmail.trim() : undefined,\n externalName: undefined,\n recipients: publicMessage ? [] : mapRecipients(params.recipientIds),\n subject: params.subject.trim(),\n body: params.body,\n bodyFormat: params.bodyFormat,\n sourceEntityType: sourceEntityType ?? undefined,\n sourceEntityId: sourceEntityId ?? undefined,\n objects: contextObjects,\n attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,\n sendViaEmail: publicMessage ? true : params.sendViaEmail,\n isDraft: input.isDraft,\n }\n}\n\nexport function useComposeSendOperation(params: ComposeOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.createableMessageTypes.some((item) => item.type === params.messageType)) {\n return params.t('messages.errors.sendFailed', 'Failed to send message.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'required'\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'optional'\n && params.contextActionRequired\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: '/api/messages',\n method: 'POST',\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useComposeDraftOperation(params: DraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const payload = buildComposePayload(params, { attachmentIds, isDraft: true })\n if (!params.messageId) {\n return {\n endpoint: '/api/messages',\n method: 'POST',\n payload,\n }\n }\n\n const draftUpdatePayload = { ...payload }\n delete draftUpdatePayload.isDraft\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH',\n payload: draftUpdatePayload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useReplySubmitOperation(params: ReplyOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/reply`,\n method: 'POST',\n payload: {\n body: params.body,\n bodyFormat: params.bodyFormat,\n replyAll: params.replyAll,\n recipients: params.recipientIds.length > 0 ? mapRecipients(params.recipientIds) : undefined,\n attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.replySuccess', 'Reply sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useForwardSubmitOperation(params: ForwardOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: () => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/forward`,\n method: 'POST',\n payload: {\n recipients: mapRecipients(params.recipientIds),\n body: params.body,\n includeAttachments: params.includeAttachments,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.forwardSuccess', 'Message forwarded.'),\n requiresAttachmentRefresh: false,\n }), [params])\n}\n\ntype SendDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useSendDraftOperation(params: SendDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\ntype UpdateDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useUpdateDraftOperation(params: UpdateDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const { isDraft: _omit, ...payload } = buildComposePayload(params, { attachmentIds, isDraft: true })\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport type MessageComposeSubmitOperation = BaseOperation\n"],
|
|
5
|
-
"mappings": "AAAA,YAAY,WAAW;AAwEvB,SAAS,oBAAoB,OAAwB;AACnD,QAAM,QAAQ,MAAM,KAAK;AACzB,MAAI,CAAC,SAAS,MAAM,SAAS,IAAK,QAAO;AAEzC,QAAM,UAAU,MAAM,QAAQ,GAAG;AACjC,MAAI,WAAW,KAAK,YAAY,MAAM,YAAY,GAAG,KAAK,YAAY,MAAM,SAAS,EAAG,QAAO;AAE/F,aAAW,QAAQ,OAAO;AACxB,QAAI,SAAS,OAAO,SAAS,OAAQ,SAAS,QAAQ,SAAS,KAAM,QAAO;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,MAAM,GAAG,OAAO;AACxC,QAAM,aAAa,MAAM,MAAM,UAAU,CAAC;AAC1C,MAAI,CAAC,aAAa,CAAC,cAAc,UAAU,SAAS,GAAI,QAAO;AAC/D,MAAI,WAAW,SAAS,OAAO,CAAC,WAAW,SAAS,GAAG,EAAG,QAAO;AACjE,MAAI,WAAW,WAAW,GAAG,KAAK,WAAW,SAAS,GAAG,KAAK,WAAW,SAAS,IAAI,EAAG,QAAO;AAEhG,QAAM,eAAe,WAAW,MAAM,GAAG;AACzC,aAAW,SAAS,cAAc;AAChC,QAAI,CAAC,MAAO,QAAO;AACnB,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,QAAO;AAAA,EAC3D;AAEA,SAAO;AACT;AAEA,SAAS,cAAc,cAA4C;AACjE,SAAO,aAAa,IAAI,CAAC,YAAY,EAAE,QAAQ,MAAM,KAAK,EAAE;AAC9D;AAEA,SAAS,oBACP,QACA,OACyB;AACzB,QAAM,gBAAgB,OAAO,eAAe;AAC5C,QAAM,mBAAmB,OAAO,eAAe,oBAAoB,OAAO,eAAe;AACzF,QAAM,iBAAiB,OAAO,eAAe,kBAAkB,OAAO,eAAe;AACrF,QAAM,8BAA8B,OAAO,qBAAqB,KAAK,CAAC,WAAW,OAAO,OAAO,OAAO,iBAAiB;AACvH,QAAM,gCAAgC,OAAO,2BACxC,OAAO,iCAAiC,aAAa,OAAO,OAAO,wBACnE,OAAO,eAAe,kBAAkB;AAC7C,QAAM,4BAA4B,gCAC7B,OAAO,2BAA2B,OAAO,oBAAoB,OAAO,eAAe,aACpF;AACJ,QAAM,6BAA6B,gCAC9B,OAAO,2BAA2B,6BAA6B,QAAQ,OAAO,eAAe,cAC9F;AACJ,QAAM,iBAAiB,OAAO,gBAC1B,CAAC;AAAA,IACD,cAAc,OAAO,cAAc;AAAA,IACnC,YAAY,OAAO,cAAc;AAAA,IACjC,UAAU,OAAO,cAAc;AAAA,IAC/B,gBAAgB;AAAA,IAChB,YAAY;AAAA,IACZ,aAAa;AAAA,EACf,CAAC,IACC;AAEJ,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,UAAU,OAAO;AAAA,IACjB,YAAY,OAAO;AAAA,IACnB,eAAe,gBAAgB,OAAO,cAAc,KAAK,IAAI;AAAA,IAC7D,cAAc;AAAA,IACd,YAAY,gBAAgB,CAAC,IAAI,cAAc,OAAO,YAAY;AAAA,IAClE,SAAS,OAAO,QAAQ,KAAK;AAAA,IAC7B,MAAM,OAAO;AAAA,IACb,YAAY,OAAO;AAAA,IACnB,kBAAkB,oBAAoB;AAAA,IACtC,gBAAgB,kBAAkB;AAAA,IAClC,SAAS;AAAA,IACT,eAAe,MAAM,cAAc,SAAS,IAAI,MAAM,gBAAgB;AAAA,IACtE,cAAc,
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n MessageComposerContextObject,\n MessageComposerProps,\n MessageTypeItem,\n} from './message-composer.types'\nimport type { MessagePriority } from './message-priority'\n\ntype Translator = ReturnType<typeof useT>\n\ntype MessageRecipient = {\n userId: string\n type: 'to'\n}\n\ntype SubmitRequest = {\n endpoint: string\n method: 'POST' | 'PATCH'\n payload: Record<string, unknown>\n}\n\ntype BaseOperation = {\n validate: () => string | null\n buildRequest: (input: { attachmentIds: string[] }) => SubmitRequest\n successMessage: string\n requiresAttachmentRefresh: boolean\n}\n\ntype ComposeOperationParams = {\n t: Translator\n messageType: string\n createableMessageTypes: MessageTypeItem[]\n priority: MessagePriority\n visibility: 'public' | 'internal'\n externalEmail: string\n recipientIds: string[]\n subject: string\n body: string\n bodyFormat: 'text' | 'markdown'\n sendViaEmail: boolean\n contextObject: MessageComposerContextObject | null\n defaultValues: MessageComposerProps['defaultValues']\n contextActionOptions: Array<{ id: string; label: string }>\n normalizedRequiredActionMode: 'none' | 'optional' | 'required'\n shouldShowContextActions: boolean\n contextActionRequired: boolean\n contextActionType: string\n}\n\ntype ReplyOperationParams = {\n t: Translator\n messageId?: string\n body: string\n bodyFormat: 'text' | 'markdown'\n replyAll: boolean\n recipientIds: string[]\n sendViaEmail: boolean\n}\n\ntype ForwardOperationParams = {\n t: Translator\n messageId?: string\n recipientIds: string[]\n body: string\n includeAttachments: boolean\n sendViaEmail: boolean\n}\n\ntype DraftOperationParams = Omit<ComposeOperationParams, 'createableMessageTypes'>\n & { messageId?: string }\n\nfunction isValidEmailAddress(value: string): boolean {\n const email = value.trim()\n if (!email || email.length > 254) return false\n\n const atIndex = email.indexOf('@')\n if (atIndex <= 0 || atIndex !== email.lastIndexOf('@') || atIndex === email.length - 1) return false\n\n for (const char of email) {\n if (char === ' ' || char === '\\t' || char === '\\n' || char === '\\r') return false\n }\n\n const localPart = email.slice(0, atIndex)\n const domainPart = email.slice(atIndex + 1)\n if (!localPart || !domainPart || localPart.length > 64) return false\n if (domainPart.length > 253 || !domainPart.includes('.')) return false\n if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) return false\n\n const domainLabels = domainPart.split('.')\n for (const label of domainLabels) {\n if (!label) return false\n if (label.startsWith('-') || label.endsWith('-')) return false\n }\n\n return true\n}\n\nfunction mapRecipients(recipientIds: string[]): MessageRecipient[] {\n return recipientIds.map((userId) => ({ userId, type: 'to' }))\n}\n\nfunction buildComposePayload(\n params: ComposeOperationParams | DraftOperationParams,\n input: { attachmentIds: string[]; isDraft: boolean },\n): Record<string, unknown> {\n const publicMessage = params.visibility === 'public'\n const sourceEntityType = params.contextObject?.sourceEntityType ?? params.defaultValues?.sourceEntityType\n const sourceEntityId = params.contextObject?.sourceEntityId ?? params.defaultValues?.sourceEntityId\n const selectedContextActionOption = params.contextActionOptions.find((option) => option.id === params.contextActionType)\n const resolvedContextActionRequired = params.shouldShowContextActions\n ? (params.normalizedRequiredActionMode === 'required' ? true : params.contextActionRequired)\n : (params.contextObject?.actionRequired ?? false)\n const resolvedContextActionType = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? params.contextActionType : params.contextObject?.actionType)\n : undefined\n const resolvedContextActionLabel = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? selectedContextActionOption?.label : params.contextObject?.actionLabel)\n : undefined\n const contextObjects = params.contextObject\n ? [{\n entityModule: params.contextObject.entityModule,\n entityType: params.contextObject.entityType,\n entityId: params.contextObject.entityId,\n actionRequired: resolvedContextActionRequired,\n actionType: resolvedContextActionType,\n actionLabel: resolvedContextActionLabel,\n }]\n : undefined\n\n return {\n type: params.messageType,\n priority: params.priority,\n visibility: params.visibility,\n externalEmail: publicMessage ? params.externalEmail.trim() : undefined,\n externalName: undefined,\n recipients: publicMessage ? [] : mapRecipients(params.recipientIds),\n subject: params.subject.trim(),\n body: params.body,\n bodyFormat: params.bodyFormat,\n sourceEntityType: sourceEntityType ?? undefined,\n sourceEntityId: sourceEntityId ?? undefined,\n objects: contextObjects,\n attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n isDraft: input.isDraft,\n }\n}\n\nexport function useComposeSendOperation(params: ComposeOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.createableMessageTypes.some((item) => item.type === params.messageType)) {\n return params.t('messages.errors.sendFailed', 'Failed to send message.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'required'\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'optional'\n && params.contextActionRequired\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: '/api/messages',\n method: 'POST',\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useComposeDraftOperation(params: DraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const payload = buildComposePayload(params, { attachmentIds, isDraft: true })\n if (!params.messageId) {\n return {\n endpoint: '/api/messages',\n method: 'POST',\n payload,\n }\n }\n\n const draftUpdatePayload = { ...payload }\n delete draftUpdatePayload.isDraft\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH',\n payload: draftUpdatePayload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useReplySubmitOperation(params: ReplyOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/reply`,\n method: 'POST',\n payload: {\n body: params.body,\n bodyFormat: params.bodyFormat,\n replyAll: params.replyAll,\n recipients: params.recipientIds.length > 0 ? mapRecipients(params.recipientIds) : undefined,\n attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.replySuccess', 'Reply sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useForwardSubmitOperation(params: ForwardOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: () => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/forward`,\n method: 'POST',\n payload: {\n recipients: mapRecipients(params.recipientIds),\n body: params.body,\n includeAttachments: params.includeAttachments,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.forwardSuccess', 'Message forwarded.'),\n requiresAttachmentRefresh: false,\n }), [params])\n}\n\ntype SendDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useSendDraftOperation(params: SendDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\ntype UpdateDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useUpdateDraftOperation(params: UpdateDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const { isDraft: _omit, ...payload } = buildComposePayload(params, { attachmentIds, isDraft: true })\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport type MessageComposeSubmitOperation = BaseOperation\n"],
|
|
5
|
+
"mappings": "AAAA,YAAY,WAAW;AAwEvB,SAAS,oBAAoB,OAAwB;AACnD,QAAM,QAAQ,MAAM,KAAK;AACzB,MAAI,CAAC,SAAS,MAAM,SAAS,IAAK,QAAO;AAEzC,QAAM,UAAU,MAAM,QAAQ,GAAG;AACjC,MAAI,WAAW,KAAK,YAAY,MAAM,YAAY,GAAG,KAAK,YAAY,MAAM,SAAS,EAAG,QAAO;AAE/F,aAAW,QAAQ,OAAO;AACxB,QAAI,SAAS,OAAO,SAAS,OAAQ,SAAS,QAAQ,SAAS,KAAM,QAAO;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,MAAM,GAAG,OAAO;AACxC,QAAM,aAAa,MAAM,MAAM,UAAU,CAAC;AAC1C,MAAI,CAAC,aAAa,CAAC,cAAc,UAAU,SAAS,GAAI,QAAO;AAC/D,MAAI,WAAW,SAAS,OAAO,CAAC,WAAW,SAAS,GAAG,EAAG,QAAO;AACjE,MAAI,WAAW,WAAW,GAAG,KAAK,WAAW,SAAS,GAAG,KAAK,WAAW,SAAS,IAAI,EAAG,QAAO;AAEhG,QAAM,eAAe,WAAW,MAAM,GAAG;AACzC,aAAW,SAAS,cAAc;AAChC,QAAI,CAAC,MAAO,QAAO;AACnB,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,QAAO;AAAA,EAC3D;AAEA,SAAO;AACT;AAEA,SAAS,cAAc,cAA4C;AACjE,SAAO,aAAa,IAAI,CAAC,YAAY,EAAE,QAAQ,MAAM,KAAK,EAAE;AAC9D;AAEA,SAAS,oBACP,QACA,OACyB;AACzB,QAAM,gBAAgB,OAAO,eAAe;AAC5C,QAAM,mBAAmB,OAAO,eAAe,oBAAoB,OAAO,eAAe;AACzF,QAAM,iBAAiB,OAAO,eAAe,kBAAkB,OAAO,eAAe;AACrF,QAAM,8BAA8B,OAAO,qBAAqB,KAAK,CAAC,WAAW,OAAO,OAAO,OAAO,iBAAiB;AACvH,QAAM,gCAAgC,OAAO,2BACxC,OAAO,iCAAiC,aAAa,OAAO,OAAO,wBACnE,OAAO,eAAe,kBAAkB;AAC7C,QAAM,4BAA4B,gCAC7B,OAAO,2BAA2B,OAAO,oBAAoB,OAAO,eAAe,aACpF;AACJ,QAAM,6BAA6B,gCAC9B,OAAO,2BAA2B,6BAA6B,QAAQ,OAAO,eAAe,cAC9F;AACJ,QAAM,iBAAiB,OAAO,gBAC1B,CAAC;AAAA,IACD,cAAc,OAAO,cAAc;AAAA,IACnC,YAAY,OAAO,cAAc;AAAA,IACjC,UAAU,OAAO,cAAc;AAAA,IAC/B,gBAAgB;AAAA,IAChB,YAAY;AAAA,IACZ,aAAa;AAAA,EACf,CAAC,IACC;AAEJ,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,UAAU,OAAO;AAAA,IACjB,YAAY,OAAO;AAAA,IACnB,eAAe,gBAAgB,OAAO,cAAc,KAAK,IAAI;AAAA,IAC7D,cAAc;AAAA,IACd,YAAY,gBAAgB,CAAC,IAAI,cAAc,OAAO,YAAY;AAAA,IAClE,SAAS,OAAO,QAAQ,KAAK;AAAA,IAC7B,MAAM,OAAO;AAAA,IACb,YAAY,OAAO;AAAA,IACnB,kBAAkB,oBAAoB;AAAA,IACtC,gBAAgB,kBAAkB;AAAA,IAClC,SAAS;AAAA,IACT,eAAe,MAAM,cAAc,SAAS,IAAI,MAAM,gBAAgB;AAAA,IACtE,cAAc,OAAO;AAAA,IACrB,SAAS,MAAM;AAAA,EACjB;AACF;AAEO,SAAS,wBAAwB,QAA+C;AACrF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,eAAe,YAAY,OAAO,aAAa,WAAW,GAAG;AACtE,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,OAAO,eAAe,YAAY,CAAC,oBAAoB,OAAO,cAAc,KAAK,CAAC,GAAG;AACvF,eAAO,OAAO,EAAE,mCAAmC,sCAAsC;AAAA,MAC3F;AACA,UAAI,CAAC,OAAO,QAAQ,KAAK,GAAG;AAC1B,eAAO,OAAO,EAAE,6BAA6B,yBAAyB;AAAA,MACxE;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,UAAI,CAAC,OAAO,uBAAuB,KAAK,CAAC,SAAS,KAAK,SAAS,OAAO,WAAW,GAAG;AACnF,eAAO,OAAO,EAAE,8BAA8B,yBAAyB;AAAA,MACzE;AACA,UACE,OAAO,4BACJ,OAAO,iCAAiC,cACxC,CAAC,OAAO,mBACX;AACA,eAAO,OAAO,EAAE,wDAAwD,mBAAmB;AAAA,MAC7F;AACA,UACE,OAAO,4BACJ,OAAO,iCAAiC,cACxC,OAAO,yBACP,CAAC,OAAO,mBACX;AACA,eAAO,OAAO,EAAE,wDAAwD,mBAAmB;AAAA,MAC7F;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU;AAAA,MACV,QAAQ;AAAA,MACR,SAAS,oBAAoB,QAAQ,EAAE,eAAe,SAAS,MAAM,CAAC;AAAA,IACxE;AAAA,IACA,gBAAgB,OAAO,EAAE,8BAA8B,eAAe;AAAA,IACtE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,yBAAyB,QAA6C;AACpF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AAAA,IAChB,cAAc,CAAC,EAAE,cAAc,MAAM;AACnC,YAAM,UAAU,oBAAoB,QAAQ,EAAE,eAAe,SAAS,KAAK,CAAC;AAC5E,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO;AAAA,UACL,UAAU;AAAA,UACV,QAAQ;AAAA,UACR;AAAA,QACF;AAAA,MACF;AAEA,YAAM,qBAAqB,EAAE,GAAG,QAAQ;AACxC,aAAO,mBAAmB;AAC1B,aAAO;AAAA,QACL,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,QAC/D,QAAQ;AAAA,QACR,SAAS;AAAA,MACX;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,6BAA6B,cAAc;AAAA,IACpE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,wBAAwB,QAA6C;AACnF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO,OAAO,EAAE,kCAAkC,6BAA6B;AAAA,MACjF;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU,iBAAiB,mBAAmB,OAAO,aAAa,EAAE,CAAC;AAAA,MACrE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,MAAM,OAAO;AAAA,QACb,YAAY,OAAO;AAAA,QACnB,UAAU,OAAO;AAAA,QACjB,YAAY,OAAO,aAAa,SAAS,IAAI,cAAc,OAAO,YAAY,IAAI;AAAA,QAClF,eAAe,cAAc,SAAS,IAAI,gBAAgB;AAAA,QAC1D,cAAc,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,+BAA+B,aAAa;AAAA,IACrE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,0BAA0B,QAA+C;AACvF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,aAAa,WAAW,GAAG;AACpC,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO,OAAO,EAAE,kCAAkC,6BAA6B;AAAA,MACjF;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,OAAO;AAAA,MACnB,UAAU,iBAAiB,mBAAmB,OAAO,aAAa,EAAE,CAAC;AAAA,MACrE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,YAAY,cAAc,OAAO,YAAY;AAAA,QAC7C,MAAM,OAAO;AAAA,QACb,oBAAoB,OAAO;AAAA,QAC3B,cAAc,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,iCAAiC,oBAAoB;AAAA,IAC9E,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAIO,SAAS,sBAAsB,QAAiD;AACrF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,eAAe,YAAY,OAAO,aAAa,WAAW,GAAG;AACtE,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,OAAO,eAAe,YAAY,CAAC,oBAAoB,OAAO,cAAc,KAAK,CAAC,GAAG;AACvF,eAAO,OAAO,EAAE,mCAAmC,sCAAsC;AAAA,MAC3F;AACA,UAAI,CAAC,OAAO,QAAQ,KAAK,GAAG;AAC1B,eAAO,OAAO,EAAE,6BAA6B,yBAAyB;AAAA,MACxE;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,MAC/D,QAAQ;AAAA,MACR,SAAS,oBAAoB,QAAQ,EAAE,eAAe,SAAS,MAAM,CAAC;AAAA,IACxE;AAAA,IACA,gBAAgB,OAAO,EAAE,8BAA8B,eAAe;AAAA,IACtE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAIO,SAAS,wBAAwB,QAAmD;AACzF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AAAA,IAChB,cAAc,CAAC,EAAE,cAAc,MAAM;AACnC,YAAM,EAAE,SAAS,OAAO,GAAG,QAAQ,IAAI,oBAAoB,QAAQ,EAAE,eAAe,SAAS,KAAK,CAAC;AACnG,aAAO;AAAA,QACL,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,QAC/D,QAAQ;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,6BAA6B,cAAc;AAAA,IACpE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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.7266.1.8e520bbe03",
|
|
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.7266.1.8e520bbe03",
|
|
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.7266.1.8e520bbe03",
|
|
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",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -369,6 +369,32 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
369
369
|
entityIds?: string[]
|
|
370
370
|
// Optional grouped layout rendered in two responsive columns (1 on mobile).
|
|
371
371
|
groups?: CrudFormGroup[]
|
|
372
|
+
/**
|
|
373
|
+
* Hide the groups whose `CrudFormGroup.id` appears in this list, so a host can
|
|
374
|
+
* omit built-in cards without rebuilding the whole `groups` array.
|
|
375
|
+
*
|
|
376
|
+
* Presentation-only: hidden groups are removed before layout, so they reserve
|
|
377
|
+
* no column space and contribute no header, sortable entry, collapsible state
|
|
378
|
+
* or validation focus target. Fields that live only in a hidden group keep
|
|
379
|
+
* their current values and are still submitted unchanged — hiding a group
|
|
380
|
+
* never clears data. A `required` field that lives only in a hidden group is
|
|
381
|
+
* skipped by the built-in required check (mirroring `visibleWhen`), but a
|
|
382
|
+
* host-supplied zod `schema` is NOT bypassed: do not hide a group whose
|
|
383
|
+
* fields the schema requires unless defaults supply them.
|
|
384
|
+
*
|
|
385
|
+
* Hiding an injection widget's card (`widget:<widgetId>`) hides the card only —
|
|
386
|
+
* the widget's handlers belong to the spot, not to its card, so `onBeforeSave`
|
|
387
|
+
* and `transformFormData` still run and its fields stay required. Do not hide a
|
|
388
|
+
* widget group that gates saving; disable the widget instead.
|
|
389
|
+
*
|
|
390
|
+
* A user's persisted group order is preserved across hiding: a hidden group
|
|
391
|
+
* keeps its saved position and returns to it once it is shown again.
|
|
392
|
+
*
|
|
393
|
+
* Ids that match no declared group are ignored (dev-only warning). Strictly
|
|
394
|
+
* additive: when the prop is absent or empty the form behaves exactly as
|
|
395
|
+
* before.
|
|
396
|
+
*/
|
|
397
|
+
hiddenGroupIds?: readonly string[]
|
|
372
398
|
// Loading state for the entire form (e.g., when loading record data)
|
|
373
399
|
isLoading?: boolean
|
|
374
400
|
loadingMessage?: string
|
|
@@ -503,6 +529,10 @@ function readByDotPath(source: Record<string, unknown> | undefined, path: string
|
|
|
503
529
|
// never reach into Object.prototype (prototype-pollution hardening).
|
|
504
530
|
const PROTO_POLLUTING_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
505
531
|
|
|
532
|
+
// Shared empty set so forms that pass no `hiddenGroupIds` keep a stable identity
|
|
533
|
+
// across renders and never invalidate the memos that depend on it.
|
|
534
|
+
const EMPTY_HIDDEN_GROUP_IDS: ReadonlySet<string> = new Set<string>()
|
|
535
|
+
|
|
506
536
|
function isProtoPollutingKey(key: string): boolean {
|
|
507
537
|
return PROTO_POLLUTING_KEYS.has(key)
|
|
508
538
|
}
|
|
@@ -769,6 +799,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
769
799
|
entityId,
|
|
770
800
|
entityIds,
|
|
771
801
|
groups,
|
|
802
|
+
hiddenGroupIds,
|
|
772
803
|
isLoading = false,
|
|
773
804
|
loadingMessage,
|
|
774
805
|
customEntity = false,
|
|
@@ -1893,6 +1924,60 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1893
1924
|
return hidden
|
|
1894
1925
|
}, [fields, values])
|
|
1895
1926
|
|
|
1927
|
+
// Joined key so a host passing a fresh array literal each render does not
|
|
1928
|
+
// invalidate every memo that depends on the resolved set.
|
|
1929
|
+
const hiddenGroupIdsKey = (hiddenGroupIds ?? []).join('\0')
|
|
1930
|
+
const hiddenGroupIdSet = React.useMemo(
|
|
1931
|
+
() => (hiddenGroupIdsKey ? new Set(hiddenGroupIdsKey.split('\0')) : EMPTY_HIDDEN_GROUP_IDS),
|
|
1932
|
+
[hiddenGroupIdsKey],
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
const placedCustomFieldIds = React.useMemo(() => {
|
|
1936
|
+
const placed = new Set<string>()
|
|
1937
|
+
for (const other of (groups ?? [])) {
|
|
1938
|
+
if (other.kind === 'customFields' || !other.fields) continue
|
|
1939
|
+
for (const entry of other.fields) {
|
|
1940
|
+
if (typeof entry === 'string') placed.add(entry)
|
|
1941
|
+
else if (entry && typeof (entry as CrudField).id === 'string') placed.add((entry as CrudField).id)
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return placed
|
|
1945
|
+
}, [groups])
|
|
1946
|
+
|
|
1947
|
+
// Field ids that appear in a hidden group and in no visible one. Those controls
|
|
1948
|
+
// are never rendered, so gating submit on them would strand the user with an
|
|
1949
|
+
// error they cannot reach — the same reason `hiddenBaseFieldIds` exists for
|
|
1950
|
+
// `visibleWhen`. A field shared with a visible group stays validated.
|
|
1951
|
+
const hiddenGroupFieldIds = React.useMemo(() => {
|
|
1952
|
+
if (hiddenGroupIdSet.size === 0) return EMPTY_HIDDEN_GROUP_IDS
|
|
1953
|
+
const groupFieldIds = (group: CrudFormGroup): string[] => {
|
|
1954
|
+
if (group.kind === 'customFields') {
|
|
1955
|
+
return cfFields.filter((field) => !placedCustomFieldIds.has(field.id)).map((field) => field.id)
|
|
1956
|
+
}
|
|
1957
|
+
return (group.fields ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.id))
|
|
1958
|
+
}
|
|
1959
|
+
const declaredGroups = groups ?? []
|
|
1960
|
+
const hidden = new Set<string>()
|
|
1961
|
+
const visible = new Set<string>()
|
|
1962
|
+
for (const group of declaredGroups) {
|
|
1963
|
+
const target = hiddenGroupIdSet.has(group.id) ? hidden : visible
|
|
1964
|
+
for (const fieldId of groupFieldIds(group)) target.add(fieldId)
|
|
1965
|
+
}
|
|
1966
|
+
// Mirror the injection fallback in `groupsWithInjectedFields`: a definition
|
|
1967
|
+
// whose target group does not exist is appended to the last declared group,
|
|
1968
|
+
// so it is hidden exactly when that fallback group is.
|
|
1969
|
+
const declaredGroupIds = new Set(declaredGroups.map((group) => group.id))
|
|
1970
|
+
const fallbackGroupId = declaredGroups[declaredGroups.length - 1]?.id
|
|
1971
|
+
for (const definition of injectedFieldDefinitions) {
|
|
1972
|
+
const targetGroupId = definition.group && declaredGroupIds.has(definition.group)
|
|
1973
|
+
? definition.group
|
|
1974
|
+
: fallbackGroupId
|
|
1975
|
+
if (targetGroupId && hiddenGroupIdSet.has(targetGroupId)) hidden.add(definition.id)
|
|
1976
|
+
}
|
|
1977
|
+
for (const fieldId of visible) hidden.delete(fieldId)
|
|
1978
|
+
return hidden
|
|
1979
|
+
}, [cfFields, groups, hiddenGroupIdSet, injectedFieldDefinitions, placedCustomFieldIds])
|
|
1980
|
+
|
|
1896
1981
|
const hiddenInjectedFieldIds = React.useMemo(() => {
|
|
1897
1982
|
const hidden = new Set<string>()
|
|
1898
1983
|
for (const definition of injectedFieldDefinitions) {
|
|
@@ -1984,6 +2069,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1984
2069
|
const field = fieldById.get(fieldId)
|
|
1985
2070
|
if (!field || field.disabled) return
|
|
1986
2071
|
if (hiddenBaseFieldIds.has(fieldId) || hiddenInjectedFieldIds.has(fieldId)) return
|
|
2072
|
+
if (hiddenGroupFieldIds.has(fieldId)) return
|
|
1987
2073
|
if (!everEditedFieldIdsRef.current.has(fieldId)) return
|
|
1988
2074
|
|
|
1989
2075
|
const nextValues = sourceValues ?? valuesRef.current
|
|
@@ -2093,6 +2179,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2093
2179
|
fieldById,
|
|
2094
2180
|
formReadOnly,
|
|
2095
2181
|
hiddenBaseFieldIds,
|
|
2182
|
+
hiddenGroupFieldIds,
|
|
2096
2183
|
hiddenInjectedFieldIds,
|
|
2097
2184
|
injectedFieldIdSet,
|
|
2098
2185
|
mapDefsForValidation,
|
|
@@ -2167,15 +2254,44 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2167
2254
|
}, [groups, injectedFieldDefinitions])
|
|
2168
2255
|
|
|
2169
2256
|
const shouldAutoGroup = (!groupsWithInjectedFields || groupsWithInjectedFields.length === 0) && injectionGroupCards.length > 0
|
|
2170
|
-
const
|
|
2257
|
+
const declaredGroupsForLayout = React.useMemo(() => {
|
|
2171
2258
|
const baseGroups = groupsWithInjectedFields && groupsWithInjectedFields.length ? groupsWithInjectedFields : []
|
|
2172
2259
|
const autoGroup = shouldAutoGroup ? [{ id: '__auto-fields__', fields: allFields }] as CrudFormGroup[] : []
|
|
2173
2260
|
return [...(baseGroups.length ? baseGroups : autoGroup), ...injectionGroupCards]
|
|
2174
2261
|
}, [allFields, groupsWithInjectedFields, injectionGroupCards, shouldAutoGroup])
|
|
2175
|
-
const useGroupedLayout = resolvedGroupsForLayout.length > 0
|
|
2176
2262
|
|
|
2177
|
-
//
|
|
2178
|
-
|
|
2263
|
+
// Hiding is applied at this single chokepoint: the grouped layout, the sortable
|
|
2264
|
+
// order, collapsible auto-expand, the autofocus scan and both column renders all
|
|
2265
|
+
// derive from `resolvedGroupsForLayout`, so filtering here keeps hidden groups
|
|
2266
|
+
// out of every one of them without threading a flag through each call site.
|
|
2267
|
+
const resolvedGroupsForLayout = React.useMemo(() => {
|
|
2268
|
+
if (hiddenGroupIdSet.size === 0) return declaredGroupsForLayout
|
|
2269
|
+
return declaredGroupsForLayout.filter((group) => !hiddenGroupIdSet.has(group.id))
|
|
2270
|
+
}, [declaredGroupsForLayout, hiddenGroupIdSet])
|
|
2271
|
+
|
|
2272
|
+
React.useEffect(() => {
|
|
2273
|
+
if (process.env.NODE_ENV === 'production' || hiddenGroupIdSet.size === 0) return
|
|
2274
|
+
const declaredIds = new Set(declaredGroupsForLayout.map((group) => group.id))
|
|
2275
|
+
for (const hiddenId of hiddenGroupIdSet) {
|
|
2276
|
+
if (!declaredIds.has(hiddenId)) {
|
|
2277
|
+
logger.warn('hiddenGroupIds names a group that does not exist on this form', { groupId: hiddenId })
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}, [declaredGroupsForLayout, hiddenGroupIdSet])
|
|
2281
|
+
|
|
2282
|
+
// Keyed off the DECLARED groups, not the filtered ones: testing
|
|
2283
|
+
// `resolvedGroupsForLayout.length > 0` would drop a fully-hidden form into the
|
|
2284
|
+
// ungrouped branch and render every field flat — the opposite of hiding them.
|
|
2285
|
+
const useGroupedLayout = declaredGroupsForLayout.length > 0
|
|
2286
|
+
|
|
2287
|
+
// Sortable group order. Keyed off the DECLARED ids, not the filtered ones:
|
|
2288
|
+
// `useGroupOrder` prunes every saved id missing from its defaults and writes the
|
|
2289
|
+
// pruned list back to localStorage on the next reorder, so passing the filtered
|
|
2290
|
+
// list would permanently erase a hidden group's saved position — a persisted side
|
|
2291
|
+
// effect from a presentation-only prop. Hidden ids are inert downstream: the
|
|
2292
|
+
// rendered order sorts `resolvedGroupsForLayout` by index into this list, and the
|
|
2293
|
+
// sortable context is built from the visible groups only.
|
|
2294
|
+
const defaultGroupIds = React.useMemo(() => declaredGroupsForLayout.map((g) => g.id), [declaredGroupsForLayout])
|
|
2179
2295
|
const { orderedIds: sortedGroupIds, reorder: reorderGroups } = useGroupOrder(
|
|
2180
2296
|
sortablePageType,
|
|
2181
2297
|
defaultGroupIds,
|
|
@@ -2210,18 +2326,6 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2210
2326
|
[injectionWidgets],
|
|
2211
2327
|
)
|
|
2212
2328
|
|
|
2213
|
-
const placedCustomFieldIds = React.useMemo(() => {
|
|
2214
|
-
const placed = new Set<string>()
|
|
2215
|
-
for (const other of (groups ?? [])) {
|
|
2216
|
-
if (other.kind === 'customFields' || !other.fields) continue
|
|
2217
|
-
for (const entry of other.fields) {
|
|
2218
|
-
if (typeof entry === 'string') placed.add(entry)
|
|
2219
|
-
else if (entry && typeof (entry as CrudField).id === 'string') placed.add((entry as CrudField).id)
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
return placed
|
|
2223
|
-
}, [groups])
|
|
2224
|
-
|
|
2225
2329
|
const resolveGroupFields = React.useCallback((g: CrudFormGroup): CrudField[] => {
|
|
2226
2330
|
if (g.kind === 'customFields') {
|
|
2227
2331
|
return cfFields.filter((f) => !placedCustomFieldIds.has(f.id))
|
|
@@ -2287,11 +2391,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2287
2391
|
if (fromCol2) return fromCol2
|
|
2288
2392
|
}
|
|
2289
2393
|
|
|
2394
|
+
// `allFields` is unfiltered, so a form whose groups are all hidden would fall
|
|
2395
|
+
// through to a field that renders nowhere and hand autofocus a target the user
|
|
2396
|
+
// cannot reach.
|
|
2290
2397
|
for (const field of allFields) {
|
|
2291
|
-
if (field?.id && !field.disabled) return field.id
|
|
2398
|
+
if (field?.id && !field.disabled && !hiddenGroupFieldIds.has(field.id)) return field.id
|
|
2292
2399
|
}
|
|
2293
2400
|
return null
|
|
2294
|
-
}, [allFields, resolveGroupFields, resolvedGroupsForLayout, useGroupedLayout])
|
|
2401
|
+
}, [allFields, hiddenGroupFieldIds, resolveGroupFields, resolvedGroupsForLayout, useGroupedLayout])
|
|
2295
2402
|
|
|
2296
2403
|
const requestSubmit = React.useCallback(() => {
|
|
2297
2404
|
if (formReadOnly) return
|
|
@@ -2872,6 +2979,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2872
2979
|
if (!field.required) continue
|
|
2873
2980
|
if (field.disabled) continue
|
|
2874
2981
|
if (hiddenBaseFieldIds.has(field.id) || hiddenInjectedFieldIds.has(field.id)) continue
|
|
2982
|
+
if (hiddenGroupFieldIds.has(field.id)) continue
|
|
2875
2983
|
const v = values[field.id]
|
|
2876
2984
|
const isArray = Array.isArray(v)
|
|
2877
2985
|
const isString = typeof v === 'string'
|