@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/backend/CrudForm.js +187 -39
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/Page.js +12 -4
- package/dist/backend/Page.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +7 -4
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/CollapsibleGroup.js +88 -0
- package/dist/backend/crud/CollapsibleGroup.js.map +7 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js +178 -0
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +7 -0
- package/dist/backend/crud/useGroupCollapse.js +24 -0
- package/dist/backend/crud/useGroupCollapse.js.map +7 -0
- package/dist/backend/crud/useGroupOrder.js +61 -0
- package/dist/backend/crud/useGroupOrder.js.map +7 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js +29 -0
- package/dist/backend/crud/usePersistedBooleanFlag.js.map +7 -0
- package/dist/backend/crud/useZoneCollapse.js +24 -0
- package/dist/backend/crud/useZoneCollapse.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +77 -33
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +82 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +16 -2
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js +3 -1
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/primitives/avatar.js +59 -0
- package/dist/primitives/avatar.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +230 -21
- package/src/backend/Page.tsx +20 -4
- package/src/backend/__tests__/AttachmentsSection.test.tsx +82 -0
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +171 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +4 -4
- package/src/backend/__tests__/NotesSection.test.tsx +63 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +9 -4
- package/src/backend/crud/CollapsibleGroup.tsx +111 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +234 -0
- package/src/backend/crud/__tests__/useGroupCollapse.test.ts +38 -0
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +63 -0
- package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +49 -0
- package/src/backend/crud/__tests__/useZoneCollapse.test.ts +31 -0
- package/src/backend/crud/useGroupCollapse.ts +22 -0
- package/src/backend/crud/useGroupOrder.ts +74 -0
- package/src/backend/crud/usePersistedBooleanFlag.ts +35 -0
- package/src/backend/crud/useZoneCollapse.ts +22 -0
- package/src/backend/detail/AttachmentsSection.tsx +81 -38
- package/src/backend/detail/NotesSection.tsx +99 -6
- package/src/backend/icons/lucideRegistry.generated.tsx +16 -2
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +3 -1
- package/src/primitives/__tests__/avatar.test.tsx +64 -0
- 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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
4058
|
+
{error ? <div className="text-xs text-status-error-text">{error}</div> : null}
|
|
3850
4059
|
</div>
|
|
3851
4060
|
)
|
|
3852
4061
|
},
|
package/src/backend/Page.tsx
CHANGED
|
@@ -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({
|
|
5
|
-
|
|
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({
|
|
29
|
-
|
|
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
|
+
})
|