@open-mercato/ui 0.9.0 → 0.9.1-develop.7462.1.94c09f158f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/CrudForm.js +30 -13
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FlashMessages.js +25 -1
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +11 -0
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +17 -5
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/progress/useProgressSse.js +13 -8
- package/dist/backend/progress/useProgressSse.js.map +2 -2
- package/package.json +5 -4
- package/src/backend/CrudForm.tsx +44 -15
- package/src/backend/FlashMessages.tsx +34 -1
- package/src/backend/__tests__/CrudForm.embeddedExtraActions.test.tsx +114 -0
- package/src/backend/__tests__/CrudForm.fieldChangeRenders.test.tsx +127 -0
- package/src/backend/__tests__/FlashMessages.test.tsx +65 -0
- package/src/backend/__tests__/useProgressSse.test.tsx +122 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -0
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +29 -0
- package/src/backend/inputs/PhoneNumberField.tsx +17 -11
- package/src/backend/inputs/__tests__/PhoneNumberField.renders.test.tsx +35 -0
- package/src/backend/progress/useProgressSse.ts +22 -8
|
@@ -5,6 +5,7 @@ import { useAppEvent } from "../injection/useAppEvent.js";
|
|
|
5
5
|
import { subscribeProgressUpdate } from "@open-mercato/shared/lib/frontend/progressEvents";
|
|
6
6
|
import { applyLocalProgressUpdate, isLocalProgressJob } from "./useProgressPoll.js";
|
|
7
7
|
const SSE_PROGRESS_SYNC_INTERVAL = 5e3;
|
|
8
|
+
const SSE_PROGRESS_IDLE_RECONCILE_INTERVAL = 3e4;
|
|
8
9
|
function isVisibleProgressJob(job) {
|
|
9
10
|
return job.meta?.hiddenFromTopBar !== true;
|
|
10
11
|
}
|
|
@@ -56,10 +57,17 @@ function useProgressSse() {
|
|
|
56
57
|
React.useEffect(() => {
|
|
57
58
|
void fetchJobs();
|
|
58
59
|
}, [fetchJobs]);
|
|
60
|
+
const hasActiveJobs = activeJobs.length > 0;
|
|
59
61
|
React.useEffect(() => {
|
|
60
|
-
let interval =
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
let interval = null;
|
|
63
|
+
const startInterval = () => {
|
|
64
|
+
if (interval) clearInterval(interval);
|
|
65
|
+
if (document.hidden) return;
|
|
66
|
+
interval = setInterval(() => {
|
|
67
|
+
void fetchJobs();
|
|
68
|
+
}, hasActiveJobs ? SSE_PROGRESS_SYNC_INTERVAL : SSE_PROGRESS_IDLE_RECONCILE_INTERVAL);
|
|
69
|
+
};
|
|
70
|
+
startInterval();
|
|
63
71
|
const onVisibilityChange = () => {
|
|
64
72
|
if (document.hidden) {
|
|
65
73
|
if (interval) {
|
|
@@ -68,10 +76,7 @@ function useProgressSse() {
|
|
|
68
76
|
}
|
|
69
77
|
} else {
|
|
70
78
|
void fetchJobs();
|
|
71
|
-
|
|
72
|
-
interval = setInterval(() => {
|
|
73
|
-
void fetchJobs();
|
|
74
|
-
}, SSE_PROGRESS_SYNC_INTERVAL);
|
|
79
|
+
startInterval();
|
|
75
80
|
}
|
|
76
81
|
};
|
|
77
82
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
@@ -79,7 +84,7 @@ function useProgressSse() {
|
|
|
79
84
|
if (interval) clearInterval(interval);
|
|
80
85
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
81
86
|
};
|
|
82
|
-
}, [fetchJobs]);
|
|
87
|
+
}, [fetchJobs, hasActiveJobs]);
|
|
83
88
|
React.useEffect(() => {
|
|
84
89
|
return subscribeProgressUpdate((detail) => {
|
|
85
90
|
applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/progress/useProgressSse.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nconst SSE_PROGRESS_SYNC_INTERVAL = 5000\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction isActiveStatus(status: ProgressJobDto['status']): boolean {\n return status === 'pending' || status === 'running'\n}\n\nfunction isTerminalStatus(status: ProgressJobDto['status']): boolean {\n return status === 'completed' || status === 'failed' || status === 'cancelled'\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),\n ...result.result!.active.filter(isVisibleProgressJob),\n ])\n setRecentlyCompleted((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),\n ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),\n ].slice(0, 10))\n setError(null)\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch progress')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n let interval: ReturnType<typeof setInterval> | null = setInterval(() => {\n
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,eAAe;AACxB,SAAS,mBAAmB;AAC5B,SAAS,+BAA+B;AAExC,SAAS,0BAA0B,0BAA0B;AAE7D,MAAM,6BAA6B;
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nconst SSE_PROGRESS_SYNC_INTERVAL = 5000\nconst SSE_PROGRESS_IDLE_RECONCILE_INTERVAL = 30000\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction isActiveStatus(status: ProgressJobDto['status']): boolean {\n return status === 'pending' || status === 'running'\n}\n\nfunction isTerminalStatus(status: ProgressJobDto['status']): boolean {\n return status === 'completed' || status === 'failed' || status === 'cancelled'\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),\n ...result.result!.active.filter(isVisibleProgressJob),\n ])\n setRecentlyCompleted((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),\n ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),\n ].slice(0, 10))\n setError(null)\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch progress')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n // The cross-process bridge (packages/events/src/bridge.ts) delivers\n // broadcast events over Postgres LISTEN/NOTIFY, which has no replay: a\n // notify published during a listener reconnect window is lost for good.\n // Poll fast while a job is active, and fall back to a slow reconciliation\n // poll while idle so a missed `progress.job.created` still surfaces.\n // See `.ai/lessons/worker-emitted-progress-needs-polling-fallback-even.md`.\n const hasActiveJobs = activeJobs.length > 0\n\n React.useEffect(() => {\n let interval: ReturnType<typeof setInterval> | null = null\n\n const startInterval = () => {\n if (interval) clearInterval(interval)\n if (document.hidden) return\n interval = setInterval(() => {\n void fetchJobs()\n }, hasActiveJobs ? SSE_PROGRESS_SYNC_INTERVAL : SSE_PROGRESS_IDLE_RECONCILE_INTERVAL)\n }\n\n startInterval()\n\n const onVisibilityChange = () => {\n if (document.hidden) {\n if (interval) {\n clearInterval(interval)\n interval = null\n }\n } else {\n void fetchJobs()\n startInterval()\n }\n }\n\n document.addEventListener('visibilitychange', onVisibilityChange)\n return () => {\n if (interval) clearInterval(interval)\n document.removeEventListener('visibilitychange', onVisibilityChange)\n }\n }, [fetchJobs, hasActiveJobs])\n\n React.useEffect(() => {\n return subscribeProgressUpdate((detail) => {\n applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)\n })\n }, [])\n\n useAppEvent(\n 'progress.job.updated',\n (event) => {\n const payload = event.payload as Partial<ProgressJobDto> & { jobId?: string }\n const jobId = payload?.jobId\n if (!jobId) {\n void fetchJobs()\n return\n }\n const status = (payload.status as ProgressJobDto['status']) ?? 'running'\n const job: ProgressJobDto = {\n id: jobId,\n jobType: payload.jobType ?? 'progress',\n name: payload.name ?? payload.jobType ?? 'Progress job',\n description: payload.description ?? null,\n meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,\n status,\n progressPercent: payload.progressPercent ?? 0,\n processedCount: payload.processedCount ?? 0,\n totalCount: payload.totalCount ?? null,\n etaSeconds: payload.etaSeconds ?? null,\n cancellable: payload.cancellable ?? false,\n startedAt: payload.startedAt ?? null,\n finishedAt: payload.finishedAt ?? null,\n errorMessage: payload.errorMessage ?? null,\n }\n\n if (isTerminalStatus(status)) {\n setActiveJobs((prev) => prev.filter((item) => item.id !== jobId))\n setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10))\n return\n }\n\n setActiveJobs((prev) =>\n upsertJob(prev, job),\n )\n },\n [fetchJobs],\n )\n\n useAppEvent('progress.job.created', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.started', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.completed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.failed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.cancelled', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchJobs()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchJobs])\n\n return { activeJobs, recentlyCompleted, isLoading, error, refresh }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1-develop.7462.1.94c09f158f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7462.1.94c09f158f",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7462.1.94c09f158f",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -186,5 +186,6 @@
|
|
|
186
186
|
"type": "git",
|
|
187
187
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
188
188
|
"directory": "packages/ui"
|
|
189
|
-
}
|
|
189
|
+
},
|
|
190
|
+
"stableVersion": "0.9.0"
|
|
190
191
|
}
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -1008,7 +1008,16 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1008
1008
|
const navigationConfirmPendingRef = React.useRef(false)
|
|
1009
1009
|
const submitNavigationBypassRef = React.useRef(false)
|
|
1010
1010
|
const submitNavigationBypassTimeoutRef = React.useRef<number | null>(null)
|
|
1011
|
-
const [hasUnsavedChanges,
|
|
1011
|
+
const [hasUnsavedChanges, setHasUnsavedChangesState] = React.useState(false)
|
|
1012
|
+
const hasUnsavedChangesRef = React.useRef(false)
|
|
1013
|
+
// Writing a state hook the value it already holds still re-runs this whole
|
|
1014
|
+
// component once before React bails out. The dirty check runs on every edit,
|
|
1015
|
+
// so the flag is only written when it actually flips.
|
|
1016
|
+
const setHasUnsavedChanges = React.useCallback((next: boolean) => {
|
|
1017
|
+
if (hasUnsavedChangesRef.current === next) return
|
|
1018
|
+
hasUnsavedChangesRef.current = next
|
|
1019
|
+
setHasUnsavedChangesState(next)
|
|
1020
|
+
}, [])
|
|
1012
1021
|
const popStateRollbackRef = React.useRef(false)
|
|
1013
1022
|
const shouldBypassUnsavedChangesGuardRef = React.useRef(shouldBypassUnsavedChangesGuard)
|
|
1014
1023
|
React.useEffect(() => {
|
|
@@ -1033,7 +1042,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1033
1042
|
const dirty = currentSnapshot !== snapshot
|
|
1034
1043
|
isDirtyRef.current = dirty
|
|
1035
1044
|
setHasUnsavedChanges(dirty)
|
|
1036
|
-
}, [embedded, trackDirtyWhenEmbedded, values])
|
|
1045
|
+
}, [embedded, trackDirtyWhenEmbedded, values, setHasUnsavedChanges])
|
|
1037
1046
|
|
|
1038
1047
|
React.useEffect(() => {
|
|
1039
1048
|
onDirtyChange?.(hasUnsavedChanges)
|
|
@@ -1083,7 +1092,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1083
1092
|
everEditedFieldIdsRef.current.clear()
|
|
1084
1093
|
isDirtyRef.current = false
|
|
1085
1094
|
setHasUnsavedChanges(false)
|
|
1086
|
-
}, [])
|
|
1095
|
+
}, [setHasUnsavedChanges])
|
|
1087
1096
|
|
|
1088
1097
|
const confirmUnsavedChanges = React.useCallback(async (): Promise<boolean> => {
|
|
1089
1098
|
if (!isDirtyRef.current || typeof window === 'undefined') return true
|
|
@@ -1201,7 +1210,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1201
1210
|
window.history.pushState = originalPushState
|
|
1202
1211
|
window.history.replaceState = originalReplaceState
|
|
1203
1212
|
}
|
|
1204
|
-
}, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded])
|
|
1213
|
+
}, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded, setHasUnsavedChanges])
|
|
1205
1214
|
|
|
1206
1215
|
const { widgets: primaryInjectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
|
|
1207
1216
|
context: injectionContext,
|
|
@@ -1629,6 +1638,15 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1629
1638
|
{extraActions}
|
|
1630
1639
|
</>
|
|
1631
1640
|
) : undefined
|
|
1641
|
+
const renderFooterActions = !hideFooterActions && !formReadOnly
|
|
1642
|
+
const embeddedExtraActions = renderFooterActions ? undefined : extraActions
|
|
1643
|
+
const embeddedHeaderActions = versionHistoryEnabled || headerInjectionAction || embeddedExtraActions ? (
|
|
1644
|
+
<>
|
|
1645
|
+
{versionHistoryEnabled ? versionHistoryAction : null}
|
|
1646
|
+
{headerInjectionAction}
|
|
1647
|
+
{embeddedExtraActions}
|
|
1648
|
+
</>
|
|
1649
|
+
) : undefined
|
|
1632
1650
|
|
|
1633
1651
|
// Auto-append custom fields for this entityId
|
|
1634
1652
|
React.useEffect(() => {
|
|
@@ -2699,18 +2717,29 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2699
2717
|
) {
|
|
2700
2718
|
activeFieldChangeDispatchRef.current = null
|
|
2701
2719
|
}
|
|
2720
|
+
// Re-run the dispatcher only when an edit queued behind this dispatch is
|
|
2721
|
+
// still waiting. Bumping unconditionally re-rendered the whole form a
|
|
2722
|
+
// second time for every keystroke, even with no widget listening.
|
|
2702
2723
|
if (
|
|
2703
2724
|
fieldChangeDispatcherMountedRef.current &&
|
|
2704
|
-
fieldChangeFormGenerationRef.current === dispatchToken.formGeneration
|
|
2725
|
+
fieldChangeFormGenerationRef.current === dispatchToken.formGeneration &&
|
|
2726
|
+
pendingFieldChangeEventsRef.current.length > 0
|
|
2705
2727
|
) {
|
|
2706
2728
|
setFieldChangeDispatchVersion((version) => version + 1)
|
|
2707
2729
|
}
|
|
2708
2730
|
})
|
|
2709
2731
|
}, [extendedInjectionEventsEnabled, fieldChangeDispatchVersion, triggerInjectionEvent, values])
|
|
2710
2732
|
|
|
2711
|
-
|
|
2712
|
-
|
|
2733
|
+
// `validateFieldOnBlur` is rebuilt whenever the form values change. Passing
|
|
2734
|
+
// that identity down would invalidate every memoized field control on each
|
|
2735
|
+
// keystroke, so the fields get a stable callback that reads the latest one.
|
|
2736
|
+
const validateFieldOnBlurRef = React.useRef(validateFieldOnBlur)
|
|
2737
|
+
React.useLayoutEffect(() => {
|
|
2738
|
+
validateFieldOnBlurRef.current = validateFieldOnBlur
|
|
2713
2739
|
}, [validateFieldOnBlur])
|
|
2740
|
+
const onBlurRequest = React.useCallback((fieldId: string) => {
|
|
2741
|
+
void validateFieldOnBlurRef.current(fieldId)
|
|
2742
|
+
}, [])
|
|
2714
2743
|
|
|
2715
2744
|
const handleFieldsetSelectionChange = React.useCallback(
|
|
2716
2745
|
(entityId: string, nextCode: string | null) => {
|
|
@@ -3899,8 +3928,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3899
3928
|
submit: formReadOnly ? undefined : { formId, pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
|
|
3900
3929
|
}}
|
|
3901
3930
|
/>
|
|
3902
|
-
) :
|
|
3903
|
-
<div className="flex justify-end gap-2 mb-2">{
|
|
3931
|
+
) : embeddedHeaderActions ? (
|
|
3932
|
+
<div className="flex justify-end gap-2 mb-2">{embeddedHeaderActions}</div>
|
|
3904
3933
|
) : null}
|
|
3905
3934
|
{contentHeader}
|
|
3906
3935
|
<DataLoader
|
|
@@ -3938,7 +3967,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3938
3967
|
{hasSecondaryColumn ? <div className={densityStackMd} data-crud-injection-region>{col2Content}</div> : null}
|
|
3939
3968
|
</div>
|
|
3940
3969
|
{formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
|
|
3941
|
-
{
|
|
3970
|
+
{renderFooterActions ? (
|
|
3942
3971
|
<FormFooter
|
|
3943
3972
|
embedded={embedded}
|
|
3944
3973
|
className={dialogFooterClass}
|
|
@@ -3952,7 +3981,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3952
3981
|
submit: { pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon }
|
|
3953
3982
|
}}
|
|
3954
3983
|
/>
|
|
3955
|
-
)}
|
|
3984
|
+
) : null}
|
|
3956
3985
|
</form>,
|
|
3957
3986
|
)}
|
|
3958
3987
|
</DataLoader>
|
|
@@ -3982,8 +4011,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3982
4011
|
submit: formReadOnly ? undefined : { formId, pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
|
|
3983
4012
|
}}
|
|
3984
4013
|
/>
|
|
3985
|
-
) :
|
|
3986
|
-
<div className="flex justify-end gap-2 mb-2">{
|
|
4014
|
+
) : embeddedHeaderActions ? (
|
|
4015
|
+
<div className="flex justify-end gap-2 mb-2">{embeddedHeaderActions}</div>
|
|
3987
4016
|
) : null}
|
|
3988
4017
|
{contentHeader}
|
|
3989
4018
|
<DataLoader
|
|
@@ -4036,7 +4065,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
4036
4065
|
})}
|
|
4037
4066
|
</div>
|
|
4038
4067
|
{formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
|
|
4039
|
-
{
|
|
4068
|
+
{renderFooterActions ? (
|
|
4040
4069
|
<FormFooter
|
|
4041
4070
|
embedded={embedded}
|
|
4042
4071
|
className={dialogFooterClass}
|
|
@@ -4050,7 +4079,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
4050
4079
|
submit: { pending: pending, label: resolvedSubmitLabel, pendingLabel: savingLabel, icon: submitIcon },
|
|
4051
4080
|
}}
|
|
4052
4081
|
/>
|
|
4053
|
-
)}
|
|
4082
|
+
) : null}
|
|
4054
4083
|
</form>
|
|
4055
4084
|
</div>,
|
|
4056
4085
|
)}
|
|
@@ -35,10 +35,37 @@ function isSameOriginFlashNavigation(): boolean {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A page that flashes from a mount effect (e.g. reading an OAuth callback's
|
|
39
|
+
// result params) can run before any `<FlashMessages />` host has attached its
|
|
40
|
+
// listener: on a full page load hydration is split across Suspense boundaries,
|
|
41
|
+
// so the page's effects may commit ahead of the hosts' and the event would be
|
|
42
|
+
// dropped. Keep the latest undelivered message briefly and let the hosts that
|
|
43
|
+
// start listening in the same commit show it, so the primary-host election
|
|
44
|
+
// stays in sync.
|
|
45
|
+
const PENDING_FLASH_MAX_AGE_MS = 5000
|
|
46
|
+
let listeningFlashHostCount = 0
|
|
47
|
+
let pendingFlash: { message: string; type: FlashKind; queuedAt: number } | null = null
|
|
48
|
+
|
|
49
|
+
function readPendingFlash(): { message: string; type: FlashKind } | null {
|
|
50
|
+
const pending = pendingFlash
|
|
51
|
+
if (!pending) return null
|
|
52
|
+
if (Date.now() - pending.queuedAt > PENDING_FLASH_MAX_AGE_MS) {
|
|
53
|
+
pendingFlash = null
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
if (pendingFlash === pending) pendingFlash = null
|
|
58
|
+
}, 0)
|
|
59
|
+
return pending
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
// Programmatic API to show a flash message without navigation.
|
|
39
63
|
// Consumers can import { flash } and call flash('text', 'error').
|
|
40
64
|
export function flash(message: string, type: FlashKind = 'info') {
|
|
41
65
|
if (typeof window === 'undefined') return
|
|
66
|
+
if (listeningFlashHostCount === 0) {
|
|
67
|
+
pendingFlash = { message, type, queuedAt: Date.now() }
|
|
68
|
+
}
|
|
42
69
|
const evt = new CustomEvent('flash', { detail: { message, type } })
|
|
43
70
|
window.dispatchEvent(evt)
|
|
44
71
|
}
|
|
@@ -222,7 +249,13 @@ function FlashMessagesInner() {
|
|
|
222
249
|
showFlash(text, t)
|
|
223
250
|
}
|
|
224
251
|
window.addEventListener('flash', handler as EventListener)
|
|
225
|
-
|
|
252
|
+
listeningFlashHostCount += 1
|
|
253
|
+
const pending = readPendingFlash()
|
|
254
|
+
if (pending) showFlash(pending.message, pending.type)
|
|
255
|
+
return () => {
|
|
256
|
+
listeningFlashHostCount -= 1
|
|
257
|
+
window.removeEventListener('flash', handler as EventListener)
|
|
258
|
+
}
|
|
226
259
|
}, [showFlash])
|
|
227
260
|
|
|
228
261
|
const handleDismiss = React.useCallback(() => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An embedded CrudForm is the body of a dialog: its footer is the only action
|
|
5
|
+
* row the user sees, and hosts put the dialog's Cancel button there by passing
|
|
6
|
+
* it through `extraActions`. Rendering that same node again in the embedded
|
|
7
|
+
* header strip produced two Cancel buttons in one dialog (issue #6750).
|
|
8
|
+
*
|
|
9
|
+
* The contract pinned here:
|
|
10
|
+
*
|
|
11
|
+
* 1. embedded + a rendered footer -> `extraActions` appear once, in the footer,
|
|
12
|
+
* 2. embedded + no footer -> they fall back to the header strip rather
|
|
13
|
+
* than disappearing, and
|
|
14
|
+
* 3. not embedded -> the intentional FormHeader/FormFooter
|
|
15
|
+
* mirroring is untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
jest.mock('next/navigation', () => ({
|
|
19
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
20
|
+
usePathname: () => '/',
|
|
21
|
+
useSearchParams: () => new URLSearchParams(),
|
|
22
|
+
}))
|
|
23
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
24
|
+
jest.mock('../confirm-dialog', () => ({
|
|
25
|
+
useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
|
|
26
|
+
}))
|
|
27
|
+
jest.mock('../custom-fields/FieldDefinitionsManager', () => {
|
|
28
|
+
const React = require('react')
|
|
29
|
+
return { __esModule: true, FieldDefinitionsManager: React.forwardRef(() => <div />) }
|
|
30
|
+
})
|
|
31
|
+
jest.mock('../utils/customFieldForms', () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
buildFormFieldFromCustomFieldDef: jest.fn(),
|
|
34
|
+
buildFormFieldsFromCustomFields: jest.fn(() => []),
|
|
35
|
+
fetchCustomFieldFormStructure: jest.fn(),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import * as React from 'react'
|
|
39
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
40
|
+
import { Button } from '../../primitives/button'
|
|
41
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
42
|
+
|
|
43
|
+
const fields: CrudField[] = [{ id: 'name', label: 'Name', type: 'text' }]
|
|
44
|
+
const groups: CrudFormGroup[] = [{ id: 'basic', title: 'Basic', column: 1, fields: ['name'] }]
|
|
45
|
+
|
|
46
|
+
type FormOptions = {
|
|
47
|
+
embedded?: boolean
|
|
48
|
+
hideFooterActions?: boolean
|
|
49
|
+
readOnly?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderForm({ embedded = true, hideFooterActions, readOnly }: FormOptions = {}) {
|
|
53
|
+
const { container } = renderWithProviders(
|
|
54
|
+
<CrudForm
|
|
55
|
+
fields={fields}
|
|
56
|
+
groups={groups}
|
|
57
|
+
initialValues={{ name: 'x' }}
|
|
58
|
+
onSubmit={jest.fn()}
|
|
59
|
+
submitLabel="Apply"
|
|
60
|
+
embedded={embedded}
|
|
61
|
+
hideFooterActions={hideFooterActions}
|
|
62
|
+
readOnly={readOnly}
|
|
63
|
+
extraActions={(
|
|
64
|
+
<Button type="button" variant="ghost">Cancel</Button>
|
|
65
|
+
)}
|
|
66
|
+
/>,
|
|
67
|
+
)
|
|
68
|
+
return container
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cancelButtons(container: HTMLElement): HTMLElement[] {
|
|
72
|
+
return Array.from(container.querySelectorAll<HTMLElement>('button')).filter(
|
|
73
|
+
(button) => button.textContent?.trim() === 'Cancel',
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('CrudForm embedded extraActions', () => {
|
|
78
|
+
it('renders a single Cancel action, in the footer beside the submit button', () => {
|
|
79
|
+
const container = renderForm()
|
|
80
|
+
const cancels = cancelButtons(container)
|
|
81
|
+
|
|
82
|
+
expect(cancels).toHaveLength(1)
|
|
83
|
+
|
|
84
|
+
const form = container.querySelector('form')
|
|
85
|
+
expect(form).not.toBeNull()
|
|
86
|
+
expect(form!.contains(cancels[0])).toBe(true)
|
|
87
|
+
|
|
88
|
+
const submit = container.querySelector<HTMLElement>('button[type="submit"]')
|
|
89
|
+
expect(submit?.textContent).toContain('Apply')
|
|
90
|
+
expect(cancels[0].parentElement).toBe(submit?.parentElement)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps the actions visible in the header strip when the footer is hidden', () => {
|
|
94
|
+
const container = renderForm({ hideFooterActions: true })
|
|
95
|
+
const cancels = cancelButtons(container)
|
|
96
|
+
|
|
97
|
+
expect(cancels).toHaveLength(1)
|
|
98
|
+
expect(container.querySelector('form')?.contains(cancels[0])).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps the actions visible in the header strip when the form is read-only', () => {
|
|
102
|
+
const container = renderForm({ readOnly: true })
|
|
103
|
+
const cancels = cancelButtons(container)
|
|
104
|
+
|
|
105
|
+
expect(cancels).toHaveLength(1)
|
|
106
|
+
expect(container.querySelector('form')?.contains(cancels[0])).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('leaves the non-embedded header/footer mirroring in place', () => {
|
|
110
|
+
const container = renderForm({ embedded: false })
|
|
111
|
+
|
|
112
|
+
expect(cancelButtons(container)).toHaveLength(2)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
const triggerEventMock = jest.fn()
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
6
|
+
usePathname: () => '/',
|
|
7
|
+
useSearchParams: () => new URLSearchParams(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
+
jest.mock('../confirm-dialog', () => ({
|
|
11
|
+
useConfirmDialog: () => ({ confirm: jest.fn(), ConfirmDialogElement: null }),
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
InjectionSpot: () => null,
|
|
16
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
17
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
18
|
+
}))
|
|
19
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const mockControlRender = jest.fn()
|
|
25
|
+
jest.mock('../../primitives/textarea', () => {
|
|
26
|
+
const actual = jest.requireActual('../../primitives/textarea')
|
|
27
|
+
const ReactActual = jest.requireActual('react')
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
Textarea: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
31
|
+
mockControlRender('textarea')
|
|
32
|
+
return ReactActual.createElement(actual.Textarea, { ...props, ref })
|
|
33
|
+
}),
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
jest.mock('../../primitives/checkbox', () => {
|
|
37
|
+
const actual = jest.requireActual('../../primitives/checkbox')
|
|
38
|
+
const ReactActual = jest.requireActual('react')
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
Checkbox: ReactActual.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
42
|
+
mockControlRender('checkbox')
|
|
43
|
+
return ReactActual.createElement(actual.Checkbox, { ...props, ref })
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
import * as React from 'react'
|
|
49
|
+
import { act, fireEvent, screen } from '@testing-library/react'
|
|
50
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
51
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
52
|
+
|
|
53
|
+
describe('CrudForm field-change render cost', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
triggerEventMock.mockReset()
|
|
56
|
+
triggerEventMock.mockImplementation(async (_event: string, data: Record<string, unknown>) => ({ ok: true, data }))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders the form once per steady-state edit when no widget answers the field-change event', async () => {
|
|
60
|
+
const groupRender = jest.fn()
|
|
61
|
+
const groups: CrudFormGroup[] = [{
|
|
62
|
+
id: 'details',
|
|
63
|
+
bare: true,
|
|
64
|
+
component: ({ values, setValue }) => {
|
|
65
|
+
groupRender()
|
|
66
|
+
return <input aria-label="Draft title" value={String(values.title ?? '')} onChange={(event) => setValue('title', event.target.value)} />
|
|
67
|
+
},
|
|
68
|
+
}]
|
|
69
|
+
renderWithProviders(
|
|
70
|
+
<CrudForm embedded fields={[]} groups={groups} initialValues={{ title: '' }} injectionSpotId="example:field-change" onSubmit={() => {}} />,
|
|
71
|
+
)
|
|
72
|
+
await act(async () => { await Promise.resolve() })
|
|
73
|
+
const rendersPerEdit: number[] = []
|
|
74
|
+
for (const value of ['a', 'ab', 'abc', 'abcd']) {
|
|
75
|
+
groupRender.mockClear()
|
|
76
|
+
triggerEventMock.mockClear()
|
|
77
|
+
await act(async () => {
|
|
78
|
+
fireEvent.change(screen.getByLabelText('Draft title'), { target: { value } })
|
|
79
|
+
await Promise.resolve()
|
|
80
|
+
await Promise.resolve()
|
|
81
|
+
})
|
|
82
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
83
|
+
expect(screen.getByLabelText('Draft title')).toHaveValue(value)
|
|
84
|
+
expect(triggerEventMock.mock.calls.filter(([event]) => event === 'onFieldChange')).toHaveLength(1)
|
|
85
|
+
rendersPerEdit.push(groupRender.mock.calls.length)
|
|
86
|
+
}
|
|
87
|
+
expect(rendersPerEdit.slice(1)).toEqual([1, 1, 1])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves the other fields alone while one field is being typed in', async () => {
|
|
91
|
+
const fields: CrudField[] = [
|
|
92
|
+
{ id: 'title', label: 'Title', type: 'text', required: true },
|
|
93
|
+
{ id: 'notes', label: 'Notes', type: 'textarea' },
|
|
94
|
+
{ id: 'flag', label: 'Flag', type: 'checkbox' },
|
|
95
|
+
]
|
|
96
|
+
const rendered = renderWithProviders(
|
|
97
|
+
<CrudForm title="Form" fields={fields} initialValues={{ title: '', notes: 'keep', flag: false }} injectionSpotId="example:field-change" onSubmit={() => {}} />,
|
|
98
|
+
{ dict: { 'ui.forms.actions.save': 'Save', 'ui.forms.errors.required': 'Required' } },
|
|
99
|
+
)
|
|
100
|
+
const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
101
|
+
const edit = async (value: string) => {
|
|
102
|
+
await act(async () => {
|
|
103
|
+
fireEvent.change(input, { target: { value } })
|
|
104
|
+
await Promise.resolve()
|
|
105
|
+
})
|
|
106
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
107
|
+
}
|
|
108
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
109
|
+
expect(mockControlRender).toHaveBeenCalledWith('textarea')
|
|
110
|
+
expect(mockControlRender).toHaveBeenCalledWith('checkbox')
|
|
111
|
+
await edit('a')
|
|
112
|
+
mockControlRender.mockClear()
|
|
113
|
+
await edit('ab')
|
|
114
|
+
await edit('abc')
|
|
115
|
+
expect(input).toHaveValue('abc')
|
|
116
|
+
expect(mockControlRender).not.toHaveBeenCalled()
|
|
117
|
+
|
|
118
|
+
// The stable blur callback must still validate against the latest values.
|
|
119
|
+
await act(async () => { fireEvent.blur(input) })
|
|
120
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
121
|
+
expect(screen.queryByText('Required')).not.toBeInTheDocument()
|
|
122
|
+
await edit('')
|
|
123
|
+
await act(async () => { fireEvent.blur(input) })
|
|
124
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)) })
|
|
125
|
+
expect(await screen.findByText('Required')).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -125,6 +125,71 @@ describe('FlashMessages', () => {
|
|
|
125
125
|
expect(screen.getAllByText('Project team updated.')).toHaveLength(1)
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Regression for #6402: on a full page load a page's mount effect can commit
|
|
130
|
+
* before any host attaches its listener, so the event used to be dropped and
|
|
131
|
+
* the user saw nothing. Rendering the flasher first reproduces that ordering.
|
|
132
|
+
*/
|
|
133
|
+
it('shows a flash dispatched before any host was listening once a host mounts', () => {
|
|
134
|
+
function FlashesOnMount() {
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
flash('Channel connected (gmail).', 'success')
|
|
137
|
+
}, [])
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
renderWithProviders(
|
|
142
|
+
<>
|
|
143
|
+
<FlashesOnMount />
|
|
144
|
+
<FlashMessages />
|
|
145
|
+
</>,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText('Channel connected (gmail).')).toBeInTheDocument()
|
|
149
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
150
|
+
expect(badge?.getAttribute('data-status')).toBe('success')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('renders an early flash once when several hosts mount in the same commit', () => {
|
|
154
|
+
flash('This connection link was already used.', 'error')
|
|
155
|
+
|
|
156
|
+
renderWithProviders(
|
|
157
|
+
<>
|
|
158
|
+
<FlashMessages />
|
|
159
|
+
<FlashMessages />
|
|
160
|
+
</>,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(screen.getAllByText('This connection link was already used.')).toHaveLength(1)
|
|
164
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
165
|
+
expect(badge?.getAttribute('data-status')).toBe('error')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not replay an early flash to a host that mounts later', () => {
|
|
169
|
+
flash('Already delivered.', 'success')
|
|
170
|
+
const first = renderWithProviders(<FlashMessages />)
|
|
171
|
+
expect(screen.getByText('Already delivered.')).toBeInTheDocument()
|
|
172
|
+
first.unmount()
|
|
173
|
+
|
|
174
|
+
act(() => {
|
|
175
|
+
jest.advanceTimersByTime(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
renderWithProviders(<FlashMessages />)
|
|
179
|
+
expect(screen.queryByText('Already delivered.')).not.toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('drops an undelivered flash that is older than the pending window', () => {
|
|
183
|
+
flash('Too old to show.', 'success')
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
jest.advanceTimersByTime(5001)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
renderWithProviders(<FlashMessages />)
|
|
190
|
+
expect(screen.queryByText('Too old to show.')).not.toBeInTheDocument()
|
|
191
|
+
})
|
|
192
|
+
|
|
128
193
|
it('auto-dismisses programmatic flashes', () => {
|
|
129
194
|
renderWithProviders(<FlashMessages />)
|
|
130
195
|
|