@open-mercato/ui 0.5.1-develop.2800.bfe2178a4f → 0.5.1-develop.2851.2854b4507f
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/DataTable.js +15 -0
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -1
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +1 -1
- package/dist/backend/crud/CollapsibleGroup.js +64 -50
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
- package/dist/backend/crud/useGroupCollapse.js +2 -2
- package/dist/backend/crud/useGroupCollapse.js.map +2 -2
- package/dist/backend/crud/usePersistedBooleanFlag.js +57 -16
- package/dist/backend/crud/usePersistedBooleanFlag.js.map +2 -2
- package/dist/backend/crud/useZoneCollapse.js +2 -2
- package/dist/backend/crud/useZoneCollapse.js.map +2 -2
- package/dist/backend/messages/SendObjectMessageDialog.js +34 -13
- package/dist/backend/messages/SendObjectMessageDialog.js.map +2 -2
- package/dist/backend/version-history/VersionHistoryAction.js +3 -3
- package/dist/backend/version-history/VersionHistoryAction.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +16 -0
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +60 -0
- package/src/backend/__tests__/DataTable.extensions.test.tsx +61 -8
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -1
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +44 -0
- package/src/backend/crud/CollapsibleGroup.tsx +12 -2
- package/src/backend/crud/CollapsibleZoneLayout.tsx +29 -4
- package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +83 -7
- package/src/backend/crud/useGroupCollapse.ts +2 -2
- package/src/backend/crud/usePersistedBooleanFlag.ts +75 -21
- package/src/backend/crud/useZoneCollapse.ts +2 -2
- package/src/backend/messages/SendObjectMessageDialog.tsx +37 -7
- package/src/backend/messages/__tests__/SendObjectMessageDialog.test.tsx +21 -0
- package/src/backend/version-history/VersionHistoryAction.tsx +4 -4
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { Send } from "lucide-react";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
6
|
import { Button } from "../../primitives/button.js";
|
|
7
|
+
import { IconButton } from "../../primitives/icon-button.js";
|
|
7
8
|
import {
|
|
8
9
|
MessageComposer
|
|
9
10
|
} from "./MessageComposer.js";
|
|
@@ -13,11 +14,16 @@ function SendObjectMessageDialog({
|
|
|
13
14
|
lockedType = "messages.defaultWithObjects",
|
|
14
15
|
requiredActionConfig = null,
|
|
15
16
|
disabled = false,
|
|
17
|
+
buttonVariant = "ghost",
|
|
18
|
+
buttonSize = "icon",
|
|
19
|
+
buttonClassName,
|
|
20
|
+
buttonLabel,
|
|
16
21
|
viewHref: _viewHref = null,
|
|
17
22
|
onSuccess
|
|
18
23
|
}) {
|
|
19
24
|
const t = useT();
|
|
20
25
|
const [open, setOpen] = React.useState(false);
|
|
26
|
+
const label = buttonLabel ?? t("messages.compose", "Compose message");
|
|
21
27
|
const openComposer = React.useCallback(() => {
|
|
22
28
|
if (disabled) return;
|
|
23
29
|
setOpen(true);
|
|
@@ -30,20 +36,35 @@ function SendObjectMessageDialog({
|
|
|
30
36
|
sourceEntityId: object.sourceEntityId ?? null,
|
|
31
37
|
previewData: object.previewData ?? null
|
|
32
38
|
}), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData]);
|
|
39
|
+
const trigger = buttonSize === "icon" && (buttonVariant === "outline" || buttonVariant === "ghost") ? /* @__PURE__ */ jsx(
|
|
40
|
+
IconButton,
|
|
41
|
+
{
|
|
42
|
+
type: "button",
|
|
43
|
+
size: "default",
|
|
44
|
+
variant: buttonVariant,
|
|
45
|
+
className: buttonClassName,
|
|
46
|
+
disabled,
|
|
47
|
+
onClick: openComposer,
|
|
48
|
+
"aria-label": label,
|
|
49
|
+
title: label,
|
|
50
|
+
children: /* @__PURE__ */ jsx(Send, { className: "size-4" })
|
|
51
|
+
}
|
|
52
|
+
) : /* @__PURE__ */ jsx(
|
|
53
|
+
Button,
|
|
54
|
+
{
|
|
55
|
+
type: "button",
|
|
56
|
+
size: buttonSize,
|
|
57
|
+
variant: buttonVariant,
|
|
58
|
+
className: buttonClassName,
|
|
59
|
+
disabled,
|
|
60
|
+
onClick: openComposer,
|
|
61
|
+
"aria-label": label,
|
|
62
|
+
title: label,
|
|
63
|
+
children: /* @__PURE__ */ jsx(Send, { className: "size-4" })
|
|
64
|
+
}
|
|
65
|
+
);
|
|
33
66
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
34
|
-
|
|
35
|
-
Button,
|
|
36
|
-
{
|
|
37
|
-
type: "button",
|
|
38
|
-
size: "icon",
|
|
39
|
-
variant: "ghost",
|
|
40
|
-
disabled,
|
|
41
|
-
onClick: openComposer,
|
|
42
|
-
"aria-label": t("messages.compose", "Compose message"),
|
|
43
|
-
title: t("messages.compose", "Compose message"),
|
|
44
|
-
children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" })
|
|
45
|
-
}
|
|
46
|
-
),
|
|
67
|
+
trigger,
|
|
47
68
|
/* @__PURE__ */ jsx(
|
|
48
69
|
MessageComposer,
|
|
49
70
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/messages/SendObjectMessageDialog.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Send } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport {\n MessageComposer,\n type MessageComposerContextObject,\n type MessageComposerProps,\n type MessageComposerRequiredActionConfig,\n} from './MessageComposer'\n\nexport type SendObjectMessageDialogProps = {\n object: MessageComposerContextObject\n defaultValues?: MessageComposerProps['defaultValues']\n lockedType?: string | null\n requiredActionConfig?: MessageComposerRequiredActionConfig | null\n disabled?: boolean\n viewHref?: string | null\n onSuccess?: MessageComposerProps['onSuccess']\n}\n\nexport function SendObjectMessageDialog({\n object,\n defaultValues,\n lockedType = 'messages.defaultWithObjects',\n requiredActionConfig = null,\n disabled = false,\n viewHref: _viewHref = null,\n onSuccess,\n}: SendObjectMessageDialogProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n\n const openComposer = React.useCallback(() => {\n if (disabled) return\n setOpen(true)\n }, [disabled])\n const contextObject = React.useMemo(() => ({\n entityModule: object.entityModule,\n entityType: object.entityType,\n entityId: object.entityId,\n sourceEntityType: object.sourceEntityType ?? null,\n sourceEntityId: object.sourceEntityId ?? null,\n previewData: object.previewData ?? null,\n }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData])\n\n
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Send } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport {\n MessageComposer,\n type MessageComposerContextObject,\n type MessageComposerProps,\n type MessageComposerRequiredActionConfig,\n} from './MessageComposer'\n\nexport type SendObjectMessageDialogProps = {\n object: MessageComposerContextObject\n defaultValues?: MessageComposerProps['defaultValues']\n lockedType?: string | null\n requiredActionConfig?: MessageComposerRequiredActionConfig | null\n disabled?: boolean\n buttonVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'muted' | 'link'\n buttonSize?: 'default' | 'sm' | 'lg' | 'icon'\n buttonClassName?: string\n buttonLabel?: string\n viewHref?: string | null\n onSuccess?: MessageComposerProps['onSuccess']\n}\n\nexport function SendObjectMessageDialog({\n object,\n defaultValues,\n lockedType = 'messages.defaultWithObjects',\n requiredActionConfig = null,\n disabled = false,\n buttonVariant = 'ghost',\n buttonSize = 'icon',\n buttonClassName,\n buttonLabel,\n viewHref: _viewHref = null,\n onSuccess,\n}: SendObjectMessageDialogProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const label = buttonLabel ?? t('messages.compose', 'Compose message')\n\n const openComposer = React.useCallback(() => {\n if (disabled) return\n setOpen(true)\n }, [disabled])\n const contextObject = React.useMemo(() => ({\n entityModule: object.entityModule,\n entityType: object.entityType,\n entityId: object.entityId,\n sourceEntityType: object.sourceEntityType ?? null,\n sourceEntityId: object.sourceEntityId ?? null,\n previewData: object.previewData ?? null,\n }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData])\n\n const trigger = buttonSize === 'icon' && (buttonVariant === 'outline' || buttonVariant === 'ghost')\n ? (\n <IconButton\n type=\"button\"\n size=\"default\"\n variant={buttonVariant}\n className={buttonClassName}\n disabled={disabled}\n onClick={openComposer}\n aria-label={label}\n title={label}\n >\n <Send className=\"size-4\" />\n </IconButton>\n )\n : (\n <Button\n type=\"button\"\n size={buttonSize}\n variant={buttonVariant}\n className={buttonClassName}\n disabled={disabled}\n onClick={openComposer}\n aria-label={label}\n title={label}\n >\n <Send className=\"size-4\" />\n </Button>\n )\n\n return (\n <>\n {trigger}\n <MessageComposer\n variant=\"compose\"\n open={open}\n onOpenChange={setOpen}\n lockedType={lockedType}\n contextObject={contextObject}\n requiredActionConfig={requiredActionConfig}\n defaultValues={defaultValues}\n onSuccess={onSuccess}\n />\n </>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAsEQ,SAmBJ,UAnBI,KAmBJ,YAnBI;AApER,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B;AAAA,EACE;AAAA,OAIK;AAgBA,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,uBAAuB;AAAA,EACvB,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA,UAAU,YAAY;AAAA,EACtB;AACF,GAAiC;AAC/B,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,QAAQ,eAAe,EAAE,oBAAoB,iBAAiB;AAEpE,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,QAAI,SAAU;AACd,YAAQ,IAAI;AAAA,EACd,GAAG,CAAC,QAAQ,CAAC;AACb,QAAM,gBAAgB,MAAM,QAAQ,OAAO;AAAA,IACzC,cAAc,OAAO;AAAA,IACrB,YAAY,OAAO;AAAA,IACnB,UAAU,OAAO;AAAA,IACjB,kBAAkB,OAAO,oBAAoB;AAAA,IAC7C,gBAAgB,OAAO,kBAAkB;AAAA,IACzC,aAAa,OAAO,eAAe;AAAA,EACrC,IAAI,CAAC,OAAO,UAAU,OAAO,cAAc,OAAO,YAAY,OAAO,gBAAgB,OAAO,kBAAkB,OAAO,WAAW,CAAC;AAEjI,QAAM,UAAU,eAAe,WAAW,kBAAkB,aAAa,kBAAkB,WAEvF;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,MAAK;AAAA,MACL,SAAS;AAAA,MACT,WAAW;AAAA,MACX;AAAA,MACA,SAAS;AAAA,MACT,cAAY;AAAA,MACZ,OAAO;AAAA,MAEP,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,EAC3B,IAGA;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,MACT,WAAW;AAAA,MACX;AAAA,MACA,SAAS;AAAA,MACT,cAAY;AAAA,MACZ,OAAO;AAAA,MAEP,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,EAC3B;AAGJ,SACE,iCACG;AAAA;AAAA,IACD;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR;AAAA,QACA,cAAc;AAAA,QACd;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Clock } from "lucide-react";
|
|
5
|
-
import {
|
|
5
|
+
import { IconButton } from "../../primitives/icon-button.js";
|
|
6
6
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
7
7
|
import { useVersionHistory } from "./useVersionHistory.js";
|
|
8
8
|
import { VersionHistoryPanel } from "./VersionHistoryPanel.js";
|
|
@@ -32,11 +32,11 @@ function VersionHistoryAction({
|
|
|
32
32
|
if (!enabled) return null;
|
|
33
33
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
34
34
|
/* @__PURE__ */ jsx(
|
|
35
|
-
|
|
35
|
+
IconButton,
|
|
36
36
|
{
|
|
37
37
|
type: "button",
|
|
38
38
|
variant: "ghost",
|
|
39
|
-
size: "
|
|
39
|
+
size: "default",
|
|
40
40
|
onClick: () => setOpen(true),
|
|
41
41
|
"aria-label": t("audit_logs.version_history.title"),
|
|
42
42
|
title: t("audit_logs.version_history.title"),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/version-history/VersionHistoryAction.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock } from 'lucide-react'\nimport {
|
|
5
|
-
"mappings": ";AAiDI,mBAUI,KAVJ;AA/CJ,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock } from 'lucide-react'\nimport { IconButton } from '../../primitives/icon-button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { VersionHistoryConfig } from './types'\nimport { useVersionHistory } from './useVersionHistory'\nimport { VersionHistoryPanel } from './VersionHistoryPanel'\n\nexport type VersionHistoryActionProps = {\n config: VersionHistoryConfig | null\n t: TranslateFn\n buttonClassName?: string\n iconClassName?: string\n canUndoRedo?: boolean\n autoCheckAcl?: boolean\n}\n\nexport function VersionHistoryAction({\n config,\n t,\n buttonClassName,\n iconClassName,\n canUndoRedo,\n autoCheckAcl,\n}: VersionHistoryActionProps) {\n const enabled = Boolean(\n config?.resourceKind\n && config?.resourceId\n && String(config.resourceId).trim().length > 0\n )\n const [open, setOpen] = React.useState(false)\n const stableConfig = React.useMemo<VersionHistoryConfig | null>(() => {\n if (!enabled || !config) return null\n return {\n resourceKind: config.resourceKind,\n resourceId: config.resourceId,\n resourceIdFallback: config.resourceIdFallback,\n organizationId: config.organizationId,\n includeRelated: config.includeRelated,\n }\n }, [enabled, config?.resourceKind, config?.resourceId, config?.resourceIdFallback, config?.organizationId, config?.includeRelated])\n const historyData = useVersionHistory(stableConfig, open)\n\n if (!enabled) return null\n\n return (\n <>\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"default\"\n onClick={() => setOpen(true)}\n aria-label={t('audit_logs.version_history.title')}\n title={t('audit_logs.version_history.title')}\n className={buttonClassName}\n >\n <Clock className={cn('size-4', iconClassName)} />\n </IconButton>\n <VersionHistoryPanel\n open={open}\n onOpenChange={setOpen}\n entries={historyData.entries}\n isLoading={historyData.isLoading}\n error={historyData.error}\n hasMore={historyData.hasMore}\n onLoadMore={historyData.loadMore}\n t={t}\n canUndoRedo={canUndoRedo}\n autoCheckAcl={autoCheckAcl}\n />\n </>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAiDI,mBAUI,KAVJ;AA/CJ,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,UAAU;AAGnB,SAAS,yBAAyB;AAClC,SAAS,2BAA2B;AAW7B,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA8B;AAC5B,QAAM,UAAU;AAAA,IACd,QAAQ,gBACH,QAAQ,cACR,OAAO,OAAO,UAAU,EAAE,KAAK,EAAE,SAAS;AAAA,EACjD;AACA,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,eAAe,MAAM,QAAqC,MAAM;AACpE,QAAI,CAAC,WAAW,CAAC,OAAQ,QAAO;AAChC,WAAO;AAAA,MACL,cAAc,OAAO;AAAA,MACrB,YAAY,OAAO;AAAA,MACnB,oBAAoB,OAAO;AAAA,MAC3B,gBAAgB,OAAO;AAAA,MACvB,gBAAgB,OAAO;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,SAAS,QAAQ,cAAc,QAAQ,YAAY,QAAQ,oBAAoB,QAAQ,gBAAgB,QAAQ,cAAc,CAAC;AAClI,QAAM,cAAc,kBAAkB,cAAc,IAAI;AAExD,MAAI,CAAC,QAAS,QAAO;AAErB,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,IAAI;AAAA,QAC3B,cAAY,EAAE,kCAAkC;AAAA,QAChD,OAAO,EAAE,kCAAkC;AAAA,QAC3C,WAAW;AAAA,QAEX,8BAAC,SAAM,WAAW,GAAG,UAAU,aAAa,GAAG;AAAA;AAAA,IACjD;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,cAAc;AAAA,QACd,SAAS,YAAY;AAAA,QACrB,WAAW,YAAY;AAAA,QACvB,OAAO,YAAY;AAAA,QACnB,SAAS,YAAY;AAAA,QACrB,YAAY,YAAY;AAAA,QACxB;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
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.2851.2854b4507f",
|
|
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.2851.2854b4507f",
|
|
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.2851.2854b4507f",
|
|
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",
|
|
@@ -204,6 +204,7 @@ export type DataTableProps<T> = {
|
|
|
204
204
|
rowClickActionIds?: string[]
|
|
205
205
|
disableRowClick?: boolean
|
|
206
206
|
bulkActions?: BulkAction<T>[]
|
|
207
|
+
selectionScopeKey?: string
|
|
207
208
|
|
|
208
209
|
// Auto FilterBar options (rendered as toolbar when provided and no custom toolbar passed)
|
|
209
210
|
searchValue?: string
|
|
@@ -861,6 +862,7 @@ export function DataTable<T>({
|
|
|
861
862
|
rowClickActionIds,
|
|
862
863
|
disableRowClick = false,
|
|
863
864
|
bulkActions: bulkActionsProp,
|
|
865
|
+
selectionScopeKey,
|
|
864
866
|
searchValue,
|
|
865
867
|
onSearchChange,
|
|
866
868
|
searchPlaceholder,
|
|
@@ -1289,6 +1291,7 @@ export function DataTable<T>({
|
|
|
1289
1291
|
const hasPropBulkActions = Array.isArray(bulkActionsProp) && bulkActionsProp.length > 0
|
|
1290
1292
|
const hasInjectedBulkActions = injectedBulkActions.length > 0 || hasPropBulkActions
|
|
1291
1293
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
|
1294
|
+
const selectionScopeKeyRef = React.useRef<string | undefined>(selectionScopeKey)
|
|
1292
1295
|
const table = useReactTable<T>({
|
|
1293
1296
|
data: clientFilteredData,
|
|
1294
1297
|
columns: mergedColumns,
|
|
@@ -1313,6 +1316,19 @@ export function DataTable<T>({
|
|
|
1313
1316
|
onRowSelectionChange: setRowSelection,
|
|
1314
1317
|
})
|
|
1315
1318
|
React.useEffect(() => { if (sortingProp) setSorting(sortingProp) }, [sortingProp])
|
|
1319
|
+
React.useEffect(() => {
|
|
1320
|
+
if (selectionScopeKey === undefined) {
|
|
1321
|
+
selectionScopeKeyRef.current = undefined
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
if (selectionScopeKeyRef.current === undefined) {
|
|
1325
|
+
selectionScopeKeyRef.current = selectionScopeKey
|
|
1326
|
+
return
|
|
1327
|
+
}
|
|
1328
|
+
if (selectionScopeKeyRef.current === selectionScopeKey) return
|
|
1329
|
+
selectionScopeKeyRef.current = selectionScopeKey
|
|
1330
|
+
setRowSelection({})
|
|
1331
|
+
}, [selectionScopeKey])
|
|
1316
1332
|
React.useEffect(() => {
|
|
1317
1333
|
if (hasInjectedBulkActions) return
|
|
1318
1334
|
if (Object.keys(rowSelection).length === 0) return
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
import { User } from 'lucide-react'
|
|
5
6
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
7
|
import { CollapsibleZoneLayout } from '../crud/CollapsibleZoneLayout'
|
|
7
8
|
|
|
@@ -76,6 +77,19 @@ describe('CollapsibleZoneLayout', () => {
|
|
|
76
77
|
writable: true,
|
|
77
78
|
value: currentWidth,
|
|
78
79
|
})
|
|
80
|
+
const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
|
|
81
|
+
return window.setTimeout(() => callback(0), 0)
|
|
82
|
+
}
|
|
83
|
+
Object.defineProperty(window, 'requestAnimationFrame', {
|
|
84
|
+
configurable: true,
|
|
85
|
+
writable: true,
|
|
86
|
+
value: requestAnimationFrameMock,
|
|
87
|
+
})
|
|
88
|
+
Object.defineProperty(globalThis, 'requestAnimationFrame', {
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: requestAnimationFrameMock,
|
|
92
|
+
})
|
|
79
93
|
|
|
80
94
|
Object.defineProperty(window, 'matchMedia', {
|
|
81
95
|
configurable: true,
|
|
@@ -168,4 +182,50 @@ describe('CollapsibleZoneLayout', () => {
|
|
|
168
182
|
expect(zone1.compareDocumentPosition(zone2) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
|
169
183
|
expect(screen.getByRole('button', { name: 'Collapse form panel' })).toBeInTheDocument()
|
|
170
184
|
})
|
|
185
|
+
|
|
186
|
+
it('expands and navigates to a section from the collapsed rail', async () => {
|
|
187
|
+
currentWidth = 1180
|
|
188
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
189
|
+
configurable: true,
|
|
190
|
+
writable: true,
|
|
191
|
+
value: currentWidth,
|
|
192
|
+
})
|
|
193
|
+
const scrollIntoView = jest.fn()
|
|
194
|
+
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
195
|
+
configurable: true,
|
|
196
|
+
writable: true,
|
|
197
|
+
value: scrollIntoView,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const { container } = renderWithProviders(
|
|
201
|
+
<CollapsibleZoneLayout
|
|
202
|
+
zone1={(
|
|
203
|
+
<div id="collapsible-group-wrapper-personalData">
|
|
204
|
+
<button type="button" aria-controls="collapsible-group-personalData">Personal group</button>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
zone2={<div>Zone 2</div>}
|
|
208
|
+
entityName="Ada Lovelace"
|
|
209
|
+
pageType="person-v2"
|
|
210
|
+
sections={[
|
|
211
|
+
{ id: 'personalData', icon: User, label: 'Personal data' },
|
|
212
|
+
]}
|
|
213
|
+
/>,
|
|
214
|
+
{ dict: {} },
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const layout = container.firstElementChild as HTMLElement
|
|
218
|
+
|
|
219
|
+
await waitFor(() => {
|
|
220
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
fireEvent.click(screen.getByRole('button', { name: 'Personal data' }))
|
|
224
|
+
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
expect(layout).toHaveAttribute('data-zone-layout-mode', 'stacked')
|
|
227
|
+
expect(screen.getByText('Personal group')).toHaveFocus()
|
|
228
|
+
})
|
|
229
|
+
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
|
|
230
|
+
})
|
|
171
231
|
})
|
|
@@ -41,19 +41,21 @@ class ResizeObserverMock {
|
|
|
41
41
|
|
|
42
42
|
function renderTable(elementProps: Record<string, unknown>) {
|
|
43
43
|
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
44
|
-
const
|
|
44
|
+
const renderElement = (props: Record<string, unknown>) => React.createElement(
|
|
45
|
+
QueryClientProvider as any,
|
|
46
|
+
{ client: queryClient },
|
|
45
47
|
React.createElement(
|
|
46
|
-
|
|
47
|
-
{
|
|
48
|
-
React.createElement(
|
|
49
|
-
I18nProvider as any,
|
|
50
|
-
{ locale: 'en', dict: {} },
|
|
51
|
-
React.createElement(DataTable as any, elementProps),
|
|
52
|
-
),
|
|
48
|
+
I18nProvider as any,
|
|
49
|
+
{ locale: 'en', dict: {} },
|
|
50
|
+
React.createElement(DataTable as any, props),
|
|
53
51
|
),
|
|
54
52
|
)
|
|
53
|
+
const view = render(
|
|
54
|
+
renderElement(elementProps),
|
|
55
|
+
)
|
|
55
56
|
return {
|
|
56
57
|
...view,
|
|
58
|
+
rerenderTable: (nextProps: Record<string, unknown>) => view.rerender(renderElement(nextProps)),
|
|
57
59
|
cleanupQueryClient: () => queryClient.clear(),
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -315,6 +317,57 @@ describe('DataTable extensions', () => {
|
|
|
315
317
|
}
|
|
316
318
|
})
|
|
317
319
|
|
|
320
|
+
it('clears selection when selectionScopeKey changes', async () => {
|
|
321
|
+
const rendered = renderTable({
|
|
322
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
323
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
324
|
+
bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
|
|
325
|
+
selectionScopeKey: 'scope-1',
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
|
|
330
|
+
|
|
331
|
+
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
|
|
332
|
+
|
|
333
|
+
rendered.rerenderTable({
|
|
334
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
335
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
336
|
+
bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
|
|
337
|
+
selectionScopeKey: 'scope-2',
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
|
|
341
|
+
} finally {
|
|
342
|
+
rendered.cleanupQueryClient()
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('keeps selection unchanged when selectionScopeKey is omitted', async () => {
|
|
347
|
+
const rendered = renderTable({
|
|
348
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
349
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
350
|
+
bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
|
|
355
|
+
|
|
356
|
+
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
|
|
357
|
+
|
|
358
|
+
rendered.rerenderTable({
|
|
359
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
360
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
361
|
+
bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
|
|
362
|
+
title: 'Messages',
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
|
|
366
|
+
} finally {
|
|
367
|
+
rendered.cleanupQueryClient()
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
318
371
|
it('applies sticky positioning to the actions column when enabled', () => {
|
|
319
372
|
const rendered = renderTable({
|
|
320
373
|
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
@@ -188,7 +188,7 @@ export function ConfirmDialog({
|
|
|
188
188
|
onClick={handleBackdropClick}
|
|
189
189
|
className={cn(
|
|
190
190
|
// Reset dialog defaults
|
|
191
|
-
"m-0 p-0 max-w-none bg-transparent border-none",
|
|
191
|
+
"m-0 p-0 max-w-none bg-transparent border-none pointer-events-auto",
|
|
192
192
|
// Backdrop styling
|
|
193
193
|
"backdrop:bg-black/50 backdrop:backdrop-blur-sm backdrop:transition-opacity",
|
|
194
194
|
// Mobile: bottom sheet
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { screen } from '@testing-library/react'
|
|
6
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
7
|
+
import { ConfirmDialog } from '../ConfirmDialog'
|
|
8
|
+
|
|
9
|
+
function installDialogPolyfill() {
|
|
10
|
+
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value(this: HTMLDialogElement) {
|
|
13
|
+
this.setAttribute('open', '')
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
Object.defineProperty(HTMLDialogElement.prototype, 'close', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
value(this: HTMLDialogElement) {
|
|
19
|
+
this.removeAttribute('open')
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ConfirmDialog', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
installDialogPolyfill()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('restores pointer events for nested modal confirmations', () => {
|
|
30
|
+
renderWithProviders(
|
|
31
|
+
<ConfirmDialog
|
|
32
|
+
open
|
|
33
|
+
onOpenChange={() => undefined}
|
|
34
|
+
onConfirm={() => undefined}
|
|
35
|
+
title="Discard unsaved changes?"
|
|
36
|
+
text="Changes will be discarded."
|
|
37
|
+
confirmText="Discard"
|
|
38
|
+
cancelText="Cancel"
|
|
39
|
+
/>,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(screen.getByRole('alertdialog').className).toEqual(expect.stringContaining('pointer-events-auto'))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -25,7 +25,7 @@ export interface CollapsibleGroupHandle {
|
|
|
25
25
|
export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, CollapsibleGroupProps>(
|
|
26
26
|
function CollapsibleGroup({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = 'right', icon, children }, ref) {
|
|
27
27
|
const t = useT()
|
|
28
|
-
const { expanded, toggle, setExpanded } = useGroupCollapse(pageType, groupId, defaultExpanded)
|
|
28
|
+
const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded)
|
|
29
29
|
const contentId = `collapsible-group-${groupId}`
|
|
30
30
|
|
|
31
31
|
React.useImperativeHandle(ref, () => ({
|
|
@@ -58,7 +58,17 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
58
58
|
) : null
|
|
59
59
|
|
|
60
60
|
return (
|
|
61
|
-
<div
|
|
61
|
+
<div
|
|
62
|
+
id={`collapsible-group-wrapper-${groupId}`}
|
|
63
|
+
className={cn(
|
|
64
|
+
'rounded-lg border bg-card',
|
|
65
|
+
!isHydrated && 'invisible',
|
|
66
|
+
errorCount > 0 && 'border-destructive',
|
|
67
|
+
)}
|
|
68
|
+
data-collapsible-group-id={groupId}
|
|
69
|
+
data-persistence-hydrated={isHydrated ? 'true' : 'false'}
|
|
70
|
+
aria-hidden={isHydrated ? undefined : true}
|
|
71
|
+
>
|
|
62
72
|
{title && (
|
|
63
73
|
<Button
|
|
64
74
|
type="button"
|
|
@@ -4,6 +4,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
6
|
import { Button } from '../../primitives/button'
|
|
7
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
7
8
|
import { useZoneCollapse } from './useZoneCollapse'
|
|
8
9
|
import type { LucideIcon } from 'lucide-react'
|
|
9
10
|
|
|
@@ -13,6 +14,8 @@ export interface ZoneSectionDescriptor {
|
|
|
13
14
|
id: string
|
|
14
15
|
icon: LucideIcon
|
|
15
16
|
label: string
|
|
17
|
+
targetId?: string
|
|
18
|
+
ariaLabel?: string
|
|
16
19
|
errorCount?: number
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -53,7 +56,7 @@ export function CollapsibleZoneLayout({
|
|
|
53
56
|
sections,
|
|
54
57
|
}: CollapsibleZoneLayoutProps) {
|
|
55
58
|
const t = useT()
|
|
56
|
-
const { collapsed, setCollapsed } = useZoneCollapse(pageType)
|
|
59
|
+
const { collapsed, setCollapsed, isHydrated } = useZoneCollapse(pageType)
|
|
57
60
|
const canCollapse = React.useSyncExternalStore(
|
|
58
61
|
subscribeViewport,
|
|
59
62
|
getViewportSnapshot,
|
|
@@ -114,6 +117,20 @@ export function CollapsibleZoneLayout({
|
|
|
114
117
|
setExpandedWhileConstrained(!canShowSideBySide)
|
|
115
118
|
}, [canCollapse, canShowSideBySide, setCollapsed])
|
|
116
119
|
|
|
120
|
+
const handleSectionActivate = React.useCallback((section: ZoneSectionDescriptor) => {
|
|
121
|
+
if (!canCollapse) return
|
|
122
|
+
setCollapsed(false)
|
|
123
|
+
setExpandedWhileConstrained(!canShowSideBySide)
|
|
124
|
+
requestAnimationFrame(() => {
|
|
125
|
+
const target =
|
|
126
|
+
document.getElementById(section.targetId ?? `collapsible-group-wrapper-${section.id}`)
|
|
127
|
+
?? document.getElementById(`collapsible-group-${section.id}`)
|
|
128
|
+
const headingButton = target?.querySelector<HTMLButtonElement>('button[aria-controls]')
|
|
129
|
+
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
130
|
+
headingButton?.focus({ preventScroll: true })
|
|
131
|
+
})
|
|
132
|
+
}, [canCollapse, canShowSideBySide, setCollapsed])
|
|
133
|
+
|
|
117
134
|
const handleCollapse = React.useCallback(() => {
|
|
118
135
|
if (!canCollapse) return
|
|
119
136
|
setExpandedWhileConstrained(false)
|
|
@@ -127,8 +144,11 @@ export function CollapsibleZoneLayout({
|
|
|
127
144
|
<div
|
|
128
145
|
ref={layoutRef}
|
|
129
146
|
data-zone-layout-mode={layoutMode}
|
|
147
|
+
data-persistence-hydrated={isHydrated ? 'true' : 'false'}
|
|
148
|
+
aria-hidden={isHydrated ? undefined : true}
|
|
130
149
|
className={cn(
|
|
131
150
|
'flex gap-4',
|
|
151
|
+
!isHydrated && 'invisible',
|
|
132
152
|
showStackedExpanded ? 'flex-col' : 'flex-col lg:flex-row',
|
|
133
153
|
)}
|
|
134
154
|
>
|
|
@@ -152,16 +172,21 @@ export function CollapsibleZoneLayout({
|
|
|
152
172
|
const SectionIcon = section.icon
|
|
153
173
|
const hasErrors = Boolean(section.errorCount && section.errorCount > 0)
|
|
154
174
|
return (
|
|
155
|
-
<
|
|
175
|
+
<IconButton
|
|
156
176
|
key={section.id}
|
|
157
|
-
|
|
177
|
+
type="button"
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="default"
|
|
180
|
+
onClick={() => handleSectionActivate(section)}
|
|
181
|
+
className="relative size-9 rounded-[10px] border border-transparent bg-muted/70 text-muted-foreground hover:border-border hover:bg-accent hover:text-accent-foreground"
|
|
158
182
|
title={section.label}
|
|
183
|
+
aria-label={section.ariaLabel ?? section.label}
|
|
159
184
|
>
|
|
160
185
|
<SectionIcon className="size-4" />
|
|
161
186
|
{hasErrors ? (
|
|
162
187
|
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive" />
|
|
163
188
|
) : null}
|
|
164
|
-
</
|
|
189
|
+
</IconButton>
|
|
165
190
|
)
|
|
166
191
|
})}
|
|
167
192
|
</div>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
|
-
import
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { renderToString } from 'react-dom/server'
|
|
4
|
+
import { hydrateRoot } from 'react-dom/client'
|
|
5
|
+
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
3
6
|
import { usePersistedBooleanFlag } from '../usePersistedBooleanFlag'
|
|
4
7
|
|
|
5
8
|
describe('usePersistedBooleanFlag', () => {
|
|
@@ -39,11 +42,84 @@ describe('usePersistedBooleanFlag', () => {
|
|
|
39
42
|
expect(localStorage.getItem('test:e')).toBe(JSON.stringify('0'))
|
|
40
43
|
})
|
|
41
44
|
|
|
42
|
-
it('
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
expect(localStorage.getItem('test:
|
|
45
|
+
it('supports the functional setValue(prev => next) form', () => {
|
|
46
|
+
localStorage.setItem('test:functional', JSON.stringify('1'))
|
|
47
|
+
const { result } = renderHook(() => usePersistedBooleanFlag('test:functional', false))
|
|
48
|
+
act(() => { result.current.setValue((prev) => !prev) })
|
|
49
|
+
expect(result.current.value).toBe(false)
|
|
50
|
+
expect(localStorage.getItem('test:functional')).toBe(JSON.stringify('0'))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does NOT write the default value to storage when nothing was touched', () => {
|
|
54
|
+
renderHook(() => usePersistedBooleanFlag('test:no-write-on-mount', true))
|
|
55
|
+
expect(localStorage.getItem('test:no-write-on-mount')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reflects stored value on the very first render (no useEffect flicker)', () => {
|
|
59
|
+
localStorage.setItem('test:first-render', JSON.stringify('1'))
|
|
60
|
+
|
|
61
|
+
const captured: boolean[] = []
|
|
62
|
+
function Probe() {
|
|
63
|
+
const { value } = usePersistedBooleanFlag('test:first-render', false)
|
|
64
|
+
captured.push(value)
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
render(React.createElement(Probe))
|
|
68
|
+
|
|
69
|
+
expect(captured.length).toBeGreaterThanOrEqual(1)
|
|
70
|
+
expect(captured[0]).toBe(true)
|
|
71
|
+
expect(captured.every((v) => v === true)).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps SSR markup hidden until the client storage snapshot is known', async () => {
|
|
75
|
+
const storageKey = 'test:ssr-hidden'
|
|
76
|
+
function Probe() {
|
|
77
|
+
const { value, isHydrated } = usePersistedBooleanFlag(storageKey, false)
|
|
78
|
+
return React.createElement(
|
|
79
|
+
'div',
|
|
80
|
+
{
|
|
81
|
+
className: isHydrated ? 'ready' : 'invisible',
|
|
82
|
+
'data-ready': isHydrated ? 'true' : 'false',
|
|
83
|
+
'data-testid': 'probe',
|
|
84
|
+
},
|
|
85
|
+
value ? 'stored' : 'default',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const html = renderToString(React.createElement(Probe))
|
|
90
|
+
expect(html).toContain('data-ready="false"')
|
|
91
|
+
expect(html).toContain('invisible')
|
|
92
|
+
|
|
93
|
+
const container = document.createElement('div')
|
|
94
|
+
container.innerHTML = html
|
|
95
|
+
document.body.appendChild(container)
|
|
96
|
+
localStorage.setItem(storageKey, JSON.stringify('1'))
|
|
97
|
+
|
|
98
|
+
let root: ReturnType<typeof hydrateRoot> | null = null
|
|
99
|
+
await act(async () => {
|
|
100
|
+
root = hydrateRoot(container, React.createElement(Probe))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
const probe = container.querySelector('[data-testid="probe"]')
|
|
105
|
+
expect(probe?.getAttribute('data-ready')).toBe('true')
|
|
106
|
+
expect(probe?.className).not.toContain('invisible')
|
|
107
|
+
expect(probe?.textContent).toBe('stored')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await act(async () => {
|
|
111
|
+
root?.unmount()
|
|
112
|
+
})
|
|
113
|
+
container.remove()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('re-renders when another instance writes the same key (same-tab sync)', () => {
|
|
117
|
+
const { result: a } = renderHook(() => usePersistedBooleanFlag('test:sync', false))
|
|
118
|
+
const { result: b } = renderHook(() => usePersistedBooleanFlag('test:sync', false))
|
|
119
|
+
expect(a.current.value).toBe(false)
|
|
120
|
+
expect(b.current.value).toBe(false)
|
|
121
|
+
act(() => { a.current.setValue(true) })
|
|
122
|
+
expect(a.current.value).toBe(true)
|
|
123
|
+
expect(b.current.value).toBe(true)
|
|
48
124
|
})
|
|
49
125
|
})
|