@open-mercato/ui 0.5.1-develop.2663.2c29774b5b → 0.5.1-develop.2681.c559bb2bc3

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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/CrudForm.js +187 -39
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/Page.js +12 -4
  5. package/dist/backend/Page.js.map +2 -2
  6. package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
  7. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  8. package/dist/backend/crud/CollapsibleGroup.js +88 -0
  9. package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
  10. package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
  11. package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
  12. package/dist/backend/crud/useGroupCollapse.js +24 -0
  13. package/dist/backend/crud/useGroupCollapse.js.map +7 -0
  14. package/dist/backend/crud/useGroupOrder.js +61 -0
  15. package/dist/backend/crud/useGroupOrder.js.map +7 -0
  16. package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
  17. package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
  18. package/dist/backend/crud/useZoneCollapse.js +24 -0
  19. package/dist/backend/crud/useZoneCollapse.js.map +7 -0
  20. package/dist/backend/detail/AttachmentsSection.js +77 -33
  21. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  22. package/dist/backend/detail/NotesSection.js +82 -6
  23. package/dist/backend/detail/NotesSection.js.map +2 -2
  24. package/dist/backend/icons/lucideRegistry.generated.js +16 -2
  25. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  26. package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
  27. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  28. package/dist/primitives/avatar.js +59 -0
  29. package/dist/primitives/avatar.js.map +7 -0
  30. package/package.json +3 -3
  31. package/src/backend/CrudForm.tsx +230 -21
  32. package/src/backend/Page.tsx +20 -4
  33. package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
  34. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
  35. package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
  36. package/src/backend/__tests__/NotesSection.test.tsx +63 -0
  37. package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
  38. package/src/backend/crud/CollapsibleGroup.tsx +111 -0
  39. package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
  40. package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
  41. package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
  42. package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
  43. package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
  44. package/src/backend/crud/useGroupCollapse.ts +22 -0
  45. package/src/backend/crud/useGroupOrder.ts +74 -0
  46. package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
  47. package/src/backend/crud/useZoneCollapse.ts +22 -0
  48. package/src/backend/detail/AttachmentsSection.tsx +81 -38
  49. package/src/backend/detail/NotesSection.tsx +99 -6
  50. package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
  51. package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
  52. package/src/primitives/__tests__/avatar.test.tsx +64 -0
  53. package/src/primitives/avatar.tsx +75 -0
@@ -0,0 +1,59 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cva } from "class-variance-authority";
4
+ import { cn } from "@open-mercato/shared/lib/utils";
5
+ const avatarVariants = cva(
6
+ "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold select-none",
7
+ {
8
+ variants: {
9
+ size: {
10
+ xs: "size-5 text-xs",
11
+ sm: "size-7 text-xs",
12
+ md: "size-9 text-sm",
13
+ lg: "size-12 text-base",
14
+ xl: "size-16 text-xl"
15
+ },
16
+ variant: {
17
+ default: "bg-primary/10 text-primary",
18
+ monochrome: "bg-muted text-muted-foreground"
19
+ }
20
+ },
21
+ defaultVariants: {
22
+ size: "md",
23
+ variant: "default"
24
+ }
25
+ }
26
+ );
27
+ function computeInitials(label) {
28
+ const trimmed = label.trim();
29
+ if (!trimmed.length) return "?";
30
+ const parts = trimmed.split(/\s+/).filter(Boolean);
31
+ if (parts.length === 1) {
32
+ return parts[0].slice(0, 2).toUpperCase();
33
+ }
34
+ const first = parts[0][0] ?? "";
35
+ const last = parts[parts.length - 1][0] ?? "";
36
+ return (first + last).toUpperCase();
37
+ }
38
+ const Avatar = React.forwardRef(
39
+ ({ className, label, src, icon, size, variant, ariaLabel, ...rest }, ref) => {
40
+ const initials = React.useMemo(() => computeInitials(label), [label]);
41
+ return /* @__PURE__ */ jsx(
42
+ "div",
43
+ {
44
+ ref,
45
+ role: "img",
46
+ "aria-label": ariaLabel ?? label,
47
+ className: cn(avatarVariants({ size, variant }), className),
48
+ ...rest,
49
+ children: src ? /* @__PURE__ */ jsx("img", { src, alt: "", className: "size-full object-cover", "aria-hidden": "true" }) : icon ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "flex items-center justify-center [&>svg]:size-[55%]", children: icon }) : /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: initials })
50
+ }
51
+ );
52
+ }
53
+ );
54
+ Avatar.displayName = "Avatar";
55
+ export {
56
+ Avatar,
57
+ avatarVariants
58
+ };
59
+ //# sourceMappingURL=avatar.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/primitives/avatar.tsx"],
4
+ "sourcesContent": ["import * as React from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst avatarVariants = cva(\n 'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold select-none',\n {\n variants: {\n size: {\n xs: 'size-5 text-xs',\n sm: 'size-7 text-xs',\n md: 'size-9 text-sm',\n lg: 'size-12 text-base',\n xl: 'size-16 text-xl',\n },\n variant: {\n default: 'bg-primary/10 text-primary',\n monochrome: 'bg-muted text-muted-foreground',\n },\n },\n defaultVariants: {\n size: 'md',\n variant: 'default',\n },\n },\n)\n\nfunction computeInitials(label: string): string {\n const trimmed = label.trim()\n if (!trimmed.length) return '?'\n const parts = trimmed.split(/\\s+/).filter(Boolean)\n if (parts.length === 1) {\n return parts[0].slice(0, 2).toUpperCase()\n }\n const first = parts[0][0] ?? ''\n const last = parts[parts.length - 1][0] ?? ''\n return (first + last).toUpperCase()\n}\n\nexport type AvatarProps = {\n label: string\n src?: string | null\n icon?: React.ReactNode\n ariaLabel?: string\n} & VariantProps<typeof avatarVariants> &\n Omit<React.HTMLAttributes<HTMLDivElement>, 'role' | 'aria-label'>\n\nexport const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(\n ({ className, label, src, icon, size, variant, ariaLabel, ...rest }, ref) => {\n const initials = React.useMemo(() => computeInitials(label), [label])\n return (\n <div\n ref={ref}\n role=\"img\"\n aria-label={ariaLabel ?? label}\n className={cn(avatarVariants({ size, variant }), className)}\n {...rest}\n >\n {src ? (\n <img src={src} alt=\"\" className=\"size-full object-cover\" aria-hidden=\"true\" />\n ) : icon ? (\n <span aria-hidden=\"true\" className=\"flex items-center justify-center [&>svg]:size-[55%]\">\n {icon}\n </span>\n ) : (\n <span aria-hidden=\"true\">{initials}</span>\n )}\n </div>\n )\n },\n)\n\nAvatar.displayName = 'Avatar'\n\nexport { avatarVariants }\n"],
5
+ "mappings": "AA2DU;AA3DV,YAAY,WAAW;AACvB,SAAS,WAA8B;AACvC,SAAS,UAAU;AAEnB,MAAM,iBAAiB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,MACA,SAAS;AAAA,QACP,SAAS;AAAA,QACT,YAAY;AAAA,MACd;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,MAAM;AAAA,MACN,SAAS;AAAA,IACX;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,OAAuB;AAC9C,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,QAAM,QAAQ,QAAQ,MAAM,KAAK,EAAE,OAAO,OAAO;AACjD,MAAI,MAAM,WAAW,GAAG;AACtB,WAAO,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,EAAE,YAAY;AAAA,EAC1C;AACA,QAAM,QAAQ,MAAM,CAAC,EAAE,CAAC,KAAK;AAC7B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,EAAE,CAAC,KAAK;AAC3C,UAAQ,QAAQ,MAAM,YAAY;AACpC;AAUO,MAAM,SAAS,MAAM;AAAA,EAC1B,CAAC,EAAE,WAAW,OAAO,KAAK,MAAM,MAAM,SAAS,WAAW,GAAG,KAAK,GAAG,QAAQ;AAC3E,UAAM,WAAW,MAAM,QAAQ,MAAM,gBAAgB,KAAK,GAAG,CAAC,KAAK,CAAC;AACpE,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,MAAK;AAAA,QACL,cAAY,aAAa;AAAA,QACzB,WAAW,GAAG,eAAe,EAAE,MAAM,QAAQ,CAAC,GAAG,SAAS;AAAA,QACzD,GAAG;AAAA,QAEH,gBACC,oBAAC,SAAI,KAAU,KAAI,IAAG,WAAU,0BAAyB,eAAY,QAAO,IAC1E,OACF,oBAAC,UAAK,eAAY,QAAO,WAAU,uDAChC,gBACH,IAEA,oBAAC,UAAK,eAAY,QAAQ,oBAAS;AAAA;AAAA,IAEvC;AAAA,EAEJ;AACF;AAEA,OAAO,cAAc;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.5.1-develop.2663.2c29774b5b",
3
+ "version": "0.5.1-develop.2681.c559bb2bc3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -132,12 +132,12 @@
132
132
  "recharts": "^3.8.1"
133
133
  },
134
134
  "peerDependencies": {
135
- "@open-mercato/shared": "0.5.1-develop.2663.2c29774b5b",
135
+ "@open-mercato/shared": "0.5.1-develop.2681.c559bb2bc3",
136
136
  "react": ">=18.0.0",
137
137
  "react-dom": ">=18.0.0"
138
138
  },
139
139
  "devDependencies": {
140
- "@open-mercato/shared": "0.5.1-develop.2663.2c29774b5b",
140
+ "@open-mercato/shared": "0.5.1-develop.2681.c559bb2bc3",
141
141
  "@testing-library/dom": "^10.4.1",
142
142
  "@testing-library/jest-dom": "^6.9.1",
143
143
  "@testing-library/react": "^16.3.1",
@@ -3,6 +3,9 @@ import * as React from 'react'
3
3
  import Link from 'next/link'
4
4
  import { z } from 'zod'
5
5
  import { useRouter } from 'next/navigation'
6
+ import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
7
+ import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable'
8
+ import { CSS } from '@dnd-kit/utilities'
6
9
  import { DataLoader } from '../primitives/DataLoader'
7
10
  import { flash } from './FlashMessages'
8
11
  import dynamic from 'next/dynamic'
@@ -71,6 +74,8 @@ import { VersionHistoryAction } from './version-history/VersionHistoryAction'
71
74
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
72
75
  import { cn } from '@open-mercato/shared/lib/utils'
73
76
  import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
77
+ import { CollapsibleGroup, type CollapsibleGroupHandle } from './crud/CollapsibleGroup'
78
+ import { useGroupOrder } from './crud/useGroupOrder'
74
79
  import { InjectedField } from './injection/InjectedField'
75
80
  import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
76
81
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
@@ -247,6 +252,18 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
247
252
  embedded?: boolean
248
253
  // Hide the footer action bar (Save/Cancel/Delete) when embedding in a custom layout
249
254
  hideFooterActions?: boolean
255
+ /**
256
+ * Opt-in: track dirty state even when `embedded` is true, AND enable the form's built-in
257
+ * navigation protection (beforeunload, link-click intercept, pushState/replaceState/popstate).
258
+ *
259
+ * Default: false — preserves pre-existing behavior where embedded forms do not maintain
260
+ * internal unsaved-changes state and leave navigation guarding to the parent page.
261
+ *
262
+ * When enabled, the parent may also subscribe to dirty transitions via `onDirtyChange` to
263
+ * mirror the state in its own UI (disabled save button, header indicators, etc.).
264
+ */
265
+ trackDirtyWhenEmbedded?: boolean
266
+ onDirtyChange?: (dirty: boolean) => void
250
267
  // Optional custom content injected between the header actions and the form body
251
268
  contentHeader?: React.ReactNode
252
269
  readOnly?: boolean
@@ -256,6 +273,15 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
256
273
  // Optional injection spot ID for widget injection
257
274
  injectionSpotId?: string
258
275
  replacementHandle?: string
276
+ // Enable collapsible group headers with localStorage persistence.
277
+ // Pass `true` to enable with auto-generated pageType, or `{ pageType }` for explicit key.
278
+ collapsibleGroups?: boolean | { pageType: string; chevronPosition?: 'left' | 'right' }
279
+ /**
280
+ * Enable drag-and-drop reordering of groups with localStorage persistence.
281
+ * NOTE: Only column-1 groups are sortable. Column-2 (sidebar) groups are fixed
282
+ * by design and always render in their declared order.
283
+ */
284
+ sortableGroups?: boolean | { pageType: string }
259
285
  // Lets the host page allow specific internal navigation targets to bypass
260
286
  // the unsaved-changes guard (e.g. navigating between sub-pages of the same record).
261
287
  // The function receives the resolved internal target (`pathname + search + hash`).
@@ -398,6 +424,21 @@ class FieldDefinitionsManagerErrorBoundary extends React.Component<
398
424
  }
399
425
  }
400
426
 
427
+ function SortableGroupItem({ id, children, disabled }: { id: string; children: React.ReactNode; disabled?: boolean }) {
428
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })
429
+ const style: React.CSSProperties = {
430
+ transform: CSS.Transform.toString(transform),
431
+ transition,
432
+ opacity: isDragging ? 0.5 : 1,
433
+ position: 'relative' as const,
434
+ }
435
+ return (
436
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
437
+ {children}
438
+ </div>
439
+ )
440
+ }
441
+
401
442
  export function CrudForm<TValues extends Record<string, unknown>>({
402
443
  schema,
403
444
  fields,
@@ -423,6 +464,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
423
464
  customEntity = false,
424
465
  embedded = false,
425
466
  hideFooterActions = false,
467
+ trackDirtyWhenEmbedded = false,
468
+ onDirtyChange,
426
469
  extraActions,
427
470
  versionHistory,
428
471
  contentHeader,
@@ -431,6 +474,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
431
474
  customFieldsetBindings,
432
475
  injectionSpotId,
433
476
  replacementHandle,
477
+ collapsibleGroups,
478
+ sortableGroups,
434
479
  shouldBypassUnsavedChangesGuard,
435
480
  }: CrudFormProps<TValues>) {
436
481
  // Ensure module field components are registered (client-side)
@@ -484,6 +529,16 @@ export function CrudForm<TValues extends Record<string, unknown>>({
484
529
  const [fieldsetEditorTarget, setFieldsetEditorTarget] = React.useState<{ entityId: string; fieldsetCode: string | null; view: 'entity' | 'fieldset' } | null>(null)
485
530
  const [isInDialog, setIsInDialog] = React.useState(false)
486
531
  const rootRef = React.useRef<HTMLDivElement | null>(null)
532
+
533
+ // Collapsible groups support
534
+ const collapsibleGroupsEnabled = Boolean(collapsibleGroups)
535
+ const collapsiblePageType = typeof collapsibleGroups === 'object' ? collapsibleGroups.pageType : formId
536
+ const collapsibleChevronPosition = typeof collapsibleGroups === 'object' ? collapsibleGroups.chevronPosition : undefined
537
+ const groupCollapseRefs = React.useRef(new Map<string, CollapsibleGroupHandle>())
538
+
539
+ // Sortable groups support
540
+ const sortableGroupsEnabled = Boolean(sortableGroups)
541
+ const sortablePageType = typeof sortableGroups === 'object' ? sortableGroups.pageType : formId
487
542
  const fieldsetManagerRef = React.useRef<FieldDefinitionsManagerHandle | null>(null)
488
543
  const resolvedEntityIdsKey = React.useMemo(() => buildResolvedEntityIdsKey(entityId, entityIds), [entityId, entityIds])
489
544
  const resolvedEntityIds = React.useMemo(
@@ -552,7 +607,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
552
607
  }, [shouldBypassUnsavedChangesGuard])
553
608
 
554
609
  React.useEffect(() => {
555
- if (embedded) {
610
+ // Preserve pre-existing behavior: embedded forms do not track dirty state by default.
611
+ // Opt-in via `trackDirtyWhenEmbedded` when the parent actually needs the callback.
612
+ if (embedded && !trackDirtyWhenEmbedded) {
556
613
  isDirtyRef.current = false
557
614
  setHasUnsavedChanges(false)
558
615
  return
@@ -567,7 +624,11 @@ export function CrudForm<TValues extends Record<string, unknown>>({
567
624
  const dirty = currentSnapshot !== snapshot
568
625
  isDirtyRef.current = dirty
569
626
  setHasUnsavedChanges(dirty)
570
- }, [embedded, values])
627
+ }, [embedded, trackDirtyWhenEmbedded, values])
628
+
629
+ React.useEffect(() => {
630
+ onDirtyChange?.(hasUnsavedChanges)
631
+ }, [hasUnsavedChanges, onDirtyChange])
571
632
 
572
633
  const allowNextNavigation = React.useCallback(() => {
573
634
  navigationPromptBypassRef.current = true
@@ -628,7 +689,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
628
689
  }, [allowNextNavigation, confirm, t])
629
690
 
630
691
  React.useEffect(() => {
631
- if (embedded || !hasUnsavedChanges) return
692
+ // Navigation protection runs when the form is tracking dirty state.
693
+ // Embedded hosts must opt in via `trackDirtyWhenEmbedded` so parent pages that manage
694
+ // their own protection (or have none by design) keep their pre-existing behavior.
695
+ if ((embedded && !trackDirtyWhenEmbedded) || !hasUnsavedChanges) return
632
696
  const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
633
697
  if (!isDirtyRef.current) return
634
698
  event.preventDefault()
@@ -709,7 +773,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
709
773
  window.history.pushState = originalPushState
710
774
  window.history.replaceState = originalReplaceState
711
775
  }
712
- }, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router])
776
+ }, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded])
713
777
 
714
778
  const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
715
779
  context: injectionContext,
@@ -1594,6 +1658,38 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1594
1658
  return [...(baseGroups.length ? baseGroups : autoGroup), ...injectionGroupCards]
1595
1659
  }, [allFields, groupsWithInjectedFields, injectionGroupCards, shouldAutoGroup])
1596
1660
  const useGroupedLayout = resolvedGroupsForLayout.length > 0
1661
+
1662
+ // Sortable group order
1663
+ const defaultGroupIds = React.useMemo(() => resolvedGroupsForLayout.map((g) => g.id), [resolvedGroupsForLayout])
1664
+ const { orderedIds: sortedGroupIds, reorder: reorderGroups } = useGroupOrder(
1665
+ sortablePageType,
1666
+ defaultGroupIds,
1667
+ )
1668
+ const sortableSensors = useSensors(
1669
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
1670
+ useSensor(KeyboardSensor),
1671
+ )
1672
+ const handleGroupDragEnd = React.useCallback((event: DragEndEvent) => {
1673
+ const { active, over } = event
1674
+ if (!over || active.id === over.id) return
1675
+ const oldIndex = sortedGroupIds.indexOf(String(active.id))
1676
+ const newIndex = sortedGroupIds.indexOf(String(over.id))
1677
+ if (oldIndex !== -1 && newIndex !== -1) reorderGroups(oldIndex, newIndex)
1678
+ }, [sortedGroupIds, reorderGroups])
1679
+
1680
+ // Auto-expand collapsed groups that contain validation errors
1681
+ React.useEffect(() => {
1682
+ if (!collapsibleGroupsEnabled || Object.keys(errors).length === 0) return
1683
+ const errorFieldIds = new Set(Object.keys(errors))
1684
+ for (const g of resolvedGroupsForLayout) {
1685
+ const groupFieldIds = (g.fields ?? []).map((f) => (typeof f === 'string' ? f : f.id))
1686
+ const hasError = groupFieldIds.some((id) => errorFieldIds.has(id))
1687
+ if (hasError) {
1688
+ groupCollapseRefs.current.get(g.id)?.expand()
1689
+ }
1690
+ }
1691
+ }, [errors, collapsibleGroupsEnabled, resolvedGroupsForLayout])
1692
+
1597
1693
  const stackedInjectionWidgets = React.useMemo(
1598
1694
  () => (injectionWidgets ?? []).filter((widget) => (widget.placement?.kind ?? 'stack') === 'stack'),
1599
1695
  [injectionWidgets],
@@ -2687,12 +2783,22 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2687
2783
  // If groups are provided, render the two-column grouped layout
2688
2784
  if (useGroupedLayout) {
2689
2785
 
2786
+ // Sort groups by user-preferred order when sortable is enabled
2787
+ const sortedGroups = sortableGroupsEnabled
2788
+ ? [...resolvedGroupsForLayout].sort((a, b) => {
2789
+ const ai = sortedGroupIds.indexOf(a.id)
2790
+ const bi = sortedGroupIds.indexOf(b.id)
2791
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
2792
+ })
2793
+ : resolvedGroupsForLayout
2794
+
2690
2795
  const col1: CrudFormGroup[] = []
2691
2796
  const col2: CrudFormGroup[] = []
2692
- for (const g of resolvedGroupsForLayout) {
2797
+ for (const g of sortedGroups) {
2693
2798
  if ((g.column ?? 1) === 2) col2.push(g)
2694
2799
  else col1.push(g)
2695
2800
  }
2801
+ const col1Ids = col1.map((g) => g.id)
2696
2802
 
2697
2803
  const renderGroupedCards = (items: CrudFormGroup[]) => {
2698
2804
  const nodes: React.ReactNode[] = []
@@ -2700,7 +2806,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2700
2806
  const isCustomFieldsGroup = g.kind === 'customFields'
2701
2807
  if (isCustomFieldsGroup) {
2702
2808
  if (isLoadingCustomFields) {
2703
- nodes.push(
2809
+ const loadingContent = (
2704
2810
  <div key={`${g.id}-loading`} className="rounded-lg border bg-card p-4">
2705
2811
  <DataLoader
2706
2812
  isLoading
@@ -2710,19 +2816,74 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2710
2816
  >
2711
2817
  <div />
2712
2818
  </DataLoader>
2713
- </div>,
2819
+ </div>
2714
2820
  )
2821
+ if (collapsibleGroupsEnabled && g.title) {
2822
+ nodes.push(
2823
+ <CollapsibleGroup
2824
+ key={`${g.id}-loading-collapsible`}
2825
+ groupId={g.id}
2826
+ title={t(g.title, g.title)}
2827
+ pageType={collapsiblePageType}
2828
+ chevronPosition={collapsibleChevronPosition}
2829
+ >
2830
+ {loadingContent}
2831
+ </CollapsibleGroup>,
2832
+ )
2833
+ } else {
2834
+ nodes.push(loadingContent)
2835
+ }
2715
2836
  continue
2716
2837
  }
2838
+
2839
+ const customFieldsInnerNodes: React.ReactNode[] = []
2717
2840
  if (g.component) {
2718
- nodes.push(
2841
+ customFieldsInnerNodes.push(
2719
2842
  <div key={`${g.id}-component`} className="rounded-lg border bg-card px-4 py-3">
2720
2843
  {g.component({ values, setValue, errors })}
2721
2844
  </div>,
2722
2845
  )
2723
2846
  }
2724
2847
  const renderedSections = renderCustomFieldsContent()
2725
- if (renderedSections.length) nodes.push(...renderedSections)
2848
+ if (renderedSections.length) customFieldsInnerNodes.push(...renderedSections)
2849
+
2850
+ if (collapsibleGroupsEnabled && g.title) {
2851
+ const customFieldCount = customFieldLayout.reduce(
2852
+ (sum, entity) => sum + entity.sections.reduce(
2853
+ (sSum, section) => sSum + section.groups.reduce(
2854
+ (gSum, group) => gSum + group.fields.length, 0,
2855
+ ), 0,
2856
+ ), 0,
2857
+ )
2858
+ const customFieldErrors = customFieldLayout.reduce(
2859
+ (sum, entity) => sum + entity.sections.reduce(
2860
+ (sSum, section) => sSum + section.groups.reduce(
2861
+ (gSum, group) => gSum + group.fields.filter((f) => errors[f.id]).length, 0,
2862
+ ), 0,
2863
+ ), 0,
2864
+ )
2865
+ nodes.push(
2866
+ <CollapsibleGroup
2867
+ key={g.id}
2868
+ ref={(handle) => {
2869
+ if (handle) groupCollapseRefs.current.set(g.id, handle)
2870
+ else groupCollapseRefs.current.delete(g.id)
2871
+ }}
2872
+ groupId={g.id}
2873
+ title={t(g.title, g.title)}
2874
+ pageType={collapsiblePageType}
2875
+ errorCount={customFieldErrors}
2876
+ fieldCount={customFieldCount}
2877
+ chevronPosition={collapsibleChevronPosition}
2878
+ >
2879
+ <div className="space-y-3">
2880
+ {customFieldsInnerNodes}
2881
+ </div>
2882
+ </CollapsibleGroup>,
2883
+ )
2884
+ } else {
2885
+ nodes.push(...customFieldsInnerNodes)
2886
+ }
2726
2887
  continue
2727
2888
  }
2728
2889
 
@@ -2734,11 +2895,11 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2734
2895
  continue
2735
2896
  }
2736
2897
  const groupFields = resolveGroupFields(g)
2737
- nodes.push(
2738
- <div key={g.id} className="rounded-lg border bg-card px-4 py-3 space-y-3">
2739
- {g.title ? (
2740
- <div className="text-sm font-medium">{t(g.title, g.title)}</div>
2741
- ) : null}
2898
+ const groupFieldIds = (g.fields ?? []).map((f) => (typeof f === 'string' ? f : f.id))
2899
+ const groupErrorCount = groupFieldIds.filter((id) => errors[id]).length
2900
+
2901
+ const groupContent = (
2902
+ <>
2742
2903
  {g.description ? <div className="text-xs text-muted-foreground">{t(g.description, g.description)}</div> : null}
2743
2904
  {componentNode ? (
2744
2905
  <div>{componentNode}</div>
@@ -2751,14 +2912,54 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2751
2912
  >
2752
2913
  {groupFields.length > 0 ? renderFields(groupFields) : <div className="min-h-[1px]" />}
2753
2914
  </DataLoader>
2754
- </div>,
2915
+ </>
2755
2916
  )
2917
+
2918
+ if (collapsibleGroupsEnabled && g.title) {
2919
+ nodes.push(
2920
+ <CollapsibleGroup
2921
+ key={g.id}
2922
+ ref={(handle) => {
2923
+ if (handle) groupCollapseRefs.current.set(g.id, handle)
2924
+ else groupCollapseRefs.current.delete(g.id)
2925
+ }}
2926
+ groupId={g.id}
2927
+ title={t(g.title, g.title)}
2928
+ pageType={collapsiblePageType}
2929
+ errorCount={groupErrorCount}
2930
+ fieldCount={groupFields.length}
2931
+ chevronPosition={collapsibleChevronPosition}
2932
+ >
2933
+ <div className="space-y-3">
2934
+ {groupContent}
2935
+ </div>
2936
+ </CollapsibleGroup>,
2937
+ )
2938
+ } else {
2939
+ nodes.push(
2940
+ <div key={g.id} className="rounded-lg border bg-card px-4 py-3 space-y-3">
2941
+ {g.title ? (
2942
+ <div className="text-sm font-medium">{t(g.title, g.title)}</div>
2943
+ ) : null}
2944
+ {groupContent}
2945
+ </div>,
2946
+ )
2947
+ }
2756
2948
  }
2757
2949
  return nodes
2758
2950
  }
2759
2951
 
2760
- const col1Content = renderGroupedCards(col1)
2952
+ const col1Nodes = renderGroupedCards(col1)
2761
2953
  const col2Content = renderGroupedCards(col2)
2954
+
2955
+ // Wrap col1 nodes in sortable items when enabled
2956
+ const col1Content = sortableGroupsEnabled
2957
+ ? col1Nodes.map((node, i) => (
2958
+ <SortableGroupItem key={col1[i]?.id ?? i} id={col1[i]?.id ?? String(i)}>
2959
+ {node}
2960
+ </SortableGroupItem>
2961
+ ))
2962
+ : col1Nodes
2762
2963
  const hasSecondaryColumn = col2Content.length > 0
2763
2964
 
2764
2965
  return (
@@ -2806,10 +3007,18 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2806
3007
  ? 'grid grid-cols-1 lg:grid-cols-[7fr_3fr] gap-4'
2807
3008
  : 'grid grid-cols-1 gap-4'}
2808
3009
  >
2809
- <div className="space-y-3">{col1Content}</div>
3010
+ {sortableGroupsEnabled ? (
3011
+ <DndContext sensors={sortableSensors} collisionDetection={closestCenter} onDragEnd={handleGroupDragEnd}>
3012
+ <SortableContext items={col1Ids} strategy={verticalListSortingStrategy}>
3013
+ <div className="space-y-3">{col1Content}</div>
3014
+ </SortableContext>
3015
+ </DndContext>
3016
+ ) : (
3017
+ <div className="space-y-3">{col1Content}</div>
3018
+ )}
2810
3019
  {hasSecondaryColumn ? <div className="space-y-3">{col2Content}</div> : null}
2811
3020
  </div>
2812
- {formError && !Object.keys(errors).length ? <div className="text-sm text-red-600">{formError}</div> : null}
3021
+ {formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
2813
3022
  {hideFooterActions || formReadOnly ? null : (
2814
3023
  <FormFooter
2815
3024
  embedded={embedded}
@@ -2904,7 +3113,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2904
3113
  )
2905
3114
  })}
2906
3115
  </div>
2907
- {formError && !Object.keys(errors).length ? <div className="text-sm text-red-600">{formError}</div> : null}
3116
+ {formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
2908
3117
  {hideFooterActions || formReadOnly ? null : (
2909
3118
  <FormFooter
2910
3119
  embedded={embedded}
@@ -3584,7 +3793,7 @@ const FieldControl = React.memo(function FieldControlImpl({
3584
3793
  {field.type !== 'checkbox' && field.label.trim().length > 0 ? (
3585
3794
  <label className="block text-sm font-medium">
3586
3795
  {field.label}
3587
- {field.required ? <span className="text-red-600"> *</span> : null}
3796
+ {field.required ? <span className="text-status-error-text"> *</span> : null}
3588
3797
  </label>
3589
3798
  ) : null}
3590
3799
  {field.type === 'text' && (
@@ -3846,7 +4055,7 @@ const FieldControl = React.memo(function FieldControlImpl({
3846
4055
  <div>{field.description}</div>
3847
4056
  </div>
3848
4057
  ) : null}
3849
- {error ? <div className="text-xs text-red-600">{error}</div> : null}
4058
+ {error ? <div className="text-xs text-status-error-text">{error}</div> : null}
3850
4059
  </div>
3851
4060
  )
3852
4061
  },
@@ -1,8 +1,16 @@
1
1
  import * as React from 'react'
2
2
  import { cn } from '@open-mercato/shared/lib/utils'
3
3
 
4
- export function Page({ children, className }: { children: React.ReactNode; className?: string }) {
5
- return <div className={cn('space-y-6', className)}>{children}</div>
4
+ export function Page({
5
+ children,
6
+ className,
7
+ ...props
8
+ }: React.HTMLAttributes<HTMLDivElement>) {
9
+ return (
10
+ <div className={cn('space-y-6', className)} {...props}>
11
+ {children}
12
+ </div>
13
+ )
6
14
  }
7
15
 
8
16
  export function PageHeader({
@@ -25,6 +33,14 @@ export function PageHeader({
25
33
  )
26
34
  }
27
35
 
28
- export function PageBody({ children, className }: { children: React.ReactNode; className?: string }) {
29
- return <div className={cn('space-y-4', className)}>{children}</div>
36
+ export function PageBody({
37
+ children,
38
+ className,
39
+ ...props
40
+ }: React.HTMLAttributes<HTMLDivElement>) {
41
+ return (
42
+ <div className={cn('space-y-4', className)} {...props}>
43
+ {children}
44
+ </div>
45
+ )
30
46
  }
@@ -0,0 +1,82 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
5
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
6
+ import { AttachmentsSection } from '../detail/AttachmentsSection'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+
9
+ jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
10
+ apiCall: jest.fn(),
11
+ }))
12
+
13
+ jest.mock('../injection/useRegisteredComponent', () => ({
14
+ useRegisteredComponent: <T,>(_handle: string, fallback?: React.ComponentType<T>) =>
15
+ fallback ?? ((() => null) as React.ComponentType<T>),
16
+ }))
17
+
18
+ jest.mock('../detail/AttachmentMetadataDialog', () => ({
19
+ AttachmentMetadataDialog: ({
20
+ open,
21
+ item,
22
+ }: {
23
+ open: boolean
24
+ item: { fileName?: string | null } | null
25
+ }) => (open ? <div data-testid="attachment-metadata-dialog">{item?.fileName ?? 'unknown'}</div> : null),
26
+ }))
27
+
28
+ jest.mock('../detail/AttachmentDeleteDialog', () => ({
29
+ AttachmentDeleteDialog: () => null,
30
+ }))
31
+
32
+ describe('AttachmentsSection', () => {
33
+ beforeEach(() => {
34
+ jest.resetAllMocks()
35
+ ;(apiCall as jest.Mock).mockImplementation((url: string) => {
36
+ if (url.startsWith('/api/attachments?')) {
37
+ return Promise.resolve({
38
+ ok: true,
39
+ status: 200,
40
+ result: {
41
+ items: [
42
+ {
43
+ id: 'attachment-1',
44
+ fileName: 'Quarterly Report.pdf',
45
+ fileSize: 2048,
46
+ mimeType: 'application/pdf',
47
+ thumbnailUrl: null,
48
+ tags: [],
49
+ assignments: [],
50
+ customFieldValues: {},
51
+ },
52
+ ],
53
+ },
54
+ response: { status: 200 },
55
+ })
56
+ }
57
+
58
+ return Promise.resolve({
59
+ ok: true,
60
+ status: 200,
61
+ result: {},
62
+ response: { status: 200 },
63
+ })
64
+ })
65
+ })
66
+
67
+ it('renders attachment cards without nesting buttons and keeps keyboard activation', async () => {
68
+ const { container } = renderWithProviders(
69
+ <AttachmentsSection entityId="customers:customer_entity" recordId="record-1" />,
70
+ { dict: {} },
71
+ )
72
+
73
+ const card = await screen.findByRole('button', { name: /quarterly report\.pdf/i })
74
+ expect(container.querySelectorAll('button button')).toHaveLength(0)
75
+
76
+ fireEvent.keyDown(card, { key: 'Enter' })
77
+
78
+ await waitFor(() => {
79
+ expect(screen.getByTestId('attachment-metadata-dialog')).toHaveTextContent('Quarterly Report.pdf')
80
+ })
81
+ })
82
+ })