@open-mercato/ui 0.8.1-develop.7263.1.bbc6db3437 → 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/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/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
|
@@ -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": "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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'
|