@open-mercato/ui 0.6.3-develop.3766.1.33102bfc91 → 0.6.3-develop.3778.1.25fdb35f2e
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/operations/LastOperationBanner.js +18 -10
- package/dist/backend/operations/LastOperationBanner.js.map +3 -3
- package/dist/backend/operations/store.js +38 -1
- package/dist/backend/operations/store.js.map +2 -2
- package/dist/primitives/password-input.js +6 -1
- package/dist/primitives/password-input.js.map +2 -2
- package/package.json +7 -7
- package/src/backend/operations/LastOperationBanner.tsx +16 -6
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +83 -1
- package/src/backend/operations/__tests__/store.test.ts +135 -0
- package/src/backend/operations/store.ts +39 -0
- package/src/primitives/__tests__/password-input.test.tsx +14 -0
- package/src/primitives/password-input.tsx +6 -1
|
@@ -6,23 +6,31 @@ import { useRouter } from "next/navigation";
|
|
|
6
6
|
import { Button } from "../../primitives/button.js";
|
|
7
7
|
import { apiCall } from "../utils/apiCall.js";
|
|
8
8
|
import { flash } from "../FlashMessages.js";
|
|
9
|
-
import { useLastOperation, markUndoSuccess } from "./store.js";
|
|
9
|
+
import { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from "./store.js";
|
|
10
10
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
11
11
|
function LastOperationBanner() {
|
|
12
12
|
const t = useT();
|
|
13
13
|
const operation = useLastOperation();
|
|
14
14
|
const [pendingToken, setPendingToken] = React.useState(null);
|
|
15
15
|
const router = useRouter();
|
|
16
|
+
const undoToken = operation?.undoToken ?? null;
|
|
17
|
+
const isPending = undoToken !== null && pendingToken === undoToken;
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (!undoToken || isPending) return;
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
dismissOperation(undoToken);
|
|
22
|
+
}, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS);
|
|
23
|
+
return () => clearTimeout(timer);
|
|
24
|
+
}, [undoToken, isPending]);
|
|
16
25
|
if (!operation) return null;
|
|
17
26
|
const rawLabel = operation.actionLabel ?? operation.commandId;
|
|
18
27
|
const translatedLabel = t(rawLabel);
|
|
19
28
|
const label = translatedLabel === rawLabel ? rawLabel : translatedLabel;
|
|
20
|
-
const isPending = pendingToken === operation.undoToken;
|
|
21
29
|
async function handleUndo() {
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [
|
|
25
|
-
setPendingToken(
|
|
30
|
+
const undoToken2 = operation?.undoToken;
|
|
31
|
+
if (!undoToken2 || isPending) return;
|
|
32
|
+
const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [undoToken2];
|
|
33
|
+
setPendingToken(undoToken2);
|
|
26
34
|
const completed = [];
|
|
27
35
|
try {
|
|
28
36
|
for (const token of tokens.slice().reverse()) {
|
|
@@ -57,10 +65,10 @@ function LastOperationBanner() {
|
|
|
57
65
|
setPendingToken(null);
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
|
-
return /* @__PURE__ */ jsxs("div", { className: "mb-4 flex items-center justify-between gap-3 rounded-md border border-
|
|
68
|
+
return /* @__PURE__ */ jsxs("div", { className: "mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3", children: [
|
|
61
69
|
/* @__PURE__ */ jsxs("div", { className: "min-w-0 truncate", children: [
|
|
62
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium text-
|
|
63
|
-
/* @__PURE__ */ jsx("span", { className: "ml-2 truncate text-
|
|
70
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-status-warning-text", children: t("audit_logs.banner.last_operation") }),
|
|
71
|
+
/* @__PURE__ */ jsx("span", { className: "ml-2 truncate text-status-warning-text", children: label })
|
|
64
72
|
] }),
|
|
65
73
|
/* @__PURE__ */ jsxs(
|
|
66
74
|
Button,
|
|
@@ -71,7 +79,7 @@ function LastOperationBanner() {
|
|
|
71
79
|
void handleUndo();
|
|
72
80
|
},
|
|
73
81
|
disabled: isPending,
|
|
74
|
-
className: "border-
|
|
82
|
+
className: "border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3",
|
|
75
83
|
children: [
|
|
76
84
|
/* @__PURE__ */ jsx(Undo2, { className: "mr-1 size-4", "aria-hidden": "true" }),
|
|
77
85
|
isPending ? t("audit_logs.actions.undoing") : t("audit_logs.banner.undo")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/operations/LastOperationBanner.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n
|
|
5
|
-
"mappings": ";
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n const undoToken = operation?.undoToken ?? null\n const isPending = undoToken !== null && pendingToken === undoToken\n\n React.useEffect(() => {\n if (!undoToken || isPending) return\n const timer = setTimeout(() => {\n dismissOperation(undoToken)\n }, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)\n return () => clearTimeout(timer)\n }, [undoToken, isPending])\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0\n ? operation.bulkUndoTokens\n : [undoToken]\n setPendingToken(undoToken)\n const completed: string[] = []\n try {\n for (const token of tokens.slice().reverse()) {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken: token }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n completed.push(token)\n }\n markUndoSuccess(tokens)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n if (completed.length > 0) markUndoSuccess(completed)\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-status-warning-text\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-status-warning-text\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["undoToken"]
|
|
7
7
|
}
|
|
@@ -4,6 +4,18 @@ const DEFAULT_STATE = { stack: [], undone: [] };
|
|
|
4
4
|
const STORAGE_KEY = "om:last-operations:v1";
|
|
5
5
|
const STACK_LIMIT = 20;
|
|
6
6
|
const LAST_OPERATION_TTL_MS = 6e4;
|
|
7
|
+
const DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 1e4;
|
|
8
|
+
function resolveAutoDismissMs(raw) {
|
|
9
|
+
if (raw == null || raw === "") return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS;
|
|
10
|
+
const parsed = Number(raw);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
12
|
+
return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS;
|
|
13
|
+
}
|
|
14
|
+
return Math.floor(parsed);
|
|
15
|
+
}
|
|
16
|
+
const LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(
|
|
17
|
+
process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
|
|
18
|
+
);
|
|
7
19
|
const STACK_RETENTION_MS = 10 * 6e4;
|
|
8
20
|
let internalState = DEFAULT_STATE;
|
|
9
21
|
if (typeof window !== "undefined") {
|
|
@@ -141,6 +153,29 @@ function markUndoSuccess(undoTokens) {
|
|
|
141
153
|
return { stack: nextStack, undone };
|
|
142
154
|
});
|
|
143
155
|
}
|
|
156
|
+
function dismissOperation(undoTokens) {
|
|
157
|
+
if (typeof window === "undefined") return;
|
|
158
|
+
const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens]);
|
|
159
|
+
if (tokenSet.size === 0) return;
|
|
160
|
+
updateState((prev) => {
|
|
161
|
+
const nextStack = [];
|
|
162
|
+
for (const entry of prev.stack) {
|
|
163
|
+
if (tokenSet.has(entry.undoToken)) continue;
|
|
164
|
+
const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null;
|
|
165
|
+
if (!bulk) {
|
|
166
|
+
nextStack.push(entry);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const remaining = bulk.filter((token) => !tokenSet.has(token));
|
|
170
|
+
if (remaining.length === bulk.length) {
|
|
171
|
+
nextStack.push(entry);
|
|
172
|
+
} else if (remaining.length > 0) {
|
|
173
|
+
nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { stack: nextStack, undone: prev.undone };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
144
179
|
function generateBulkId(seed) {
|
|
145
180
|
const cryptoRef = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
146
181
|
if (cryptoRef && typeof cryptoRef.randomUUID === "function") {
|
|
@@ -221,11 +256,13 @@ function clearAllOperations() {
|
|
|
221
256
|
emit();
|
|
222
257
|
}
|
|
223
258
|
const operationStackConstants = {
|
|
224
|
-
LAST_OPERATION_TTL_MS
|
|
259
|
+
LAST_OPERATION_TTL_MS,
|
|
260
|
+
LAST_OPERATION_AUTO_DISMISS_MS
|
|
225
261
|
};
|
|
226
262
|
export {
|
|
227
263
|
clearAllOperations,
|
|
228
264
|
coalesceLastOperations,
|
|
265
|
+
dismissOperation,
|
|
229
266
|
getLastOperation,
|
|
230
267
|
hasRedoCandidate,
|
|
231
268
|
markRedoConsumed,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/operations/store.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 10_000\n\nfunction resolveAutoDismissMs(raw: string | undefined): number {\n if (raw == null || raw === '') return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS\n }\n return Math.floor(parsed)\n}\n\nconst LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(\n process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS,\n)\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport function dismissOperation(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n if (tokenSet.has(entry.undoToken)) continue\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n nextStack.push(entry)\n continue\n }\n const remaining = bulk.filter((token) => !tokenSet.has(token))\n if (remaining.length === bulk.length) {\n nextStack.push(entry)\n } else if (remaining.length > 0) {\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n return { stack: nextStack, undone: prev.undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n LAST_OPERATION_AUTO_DISMISS_MS,\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["raw"]
|
|
7
7
|
}
|
|
@@ -38,7 +38,12 @@ const PasswordInput = React.forwardRef(
|
|
|
38
38
|
ref,
|
|
39
39
|
type: revealed ? "text" : "password",
|
|
40
40
|
disabled,
|
|
41
|
-
className: cn(
|
|
41
|
+
className: cn(
|
|
42
|
+
inputElementVariants({ size }),
|
|
43
|
+
// Suppress Edge/IE native password reveal + clear controls — we render our own toggle.
|
|
44
|
+
"[&::-ms-reveal]:hidden [&::-ms-clear]:hidden",
|
|
45
|
+
inputClassName
|
|
46
|
+
),
|
|
42
47
|
...props
|
|
43
48
|
}
|
|
44
49
|
),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/password-input.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Eye, EyeOff, Lock } from 'lucide-react'\nimport type { VariantProps } from 'class-variance-authority'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { inputWrapperVariants, inputElementVariants } from './input'\n\nexport type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &\n VariantProps<typeof inputWrapperVariants> & {\n /** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */\n showLockIcon?: boolean\n /** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */\n revealable?: boolean\n /** Optional controlled reveal state (otherwise managed internally). */\n revealed?: boolean\n /** Called when the reveal state changes. */\n onRevealedChange?: (next: boolean) => void\n /** Optional className on the wrapper. */\n className?: string\n /** Optional className on the inner `<input>`. */\n inputClassName?: string\n /** Translated aria-label for the reveal button when password is hidden. */\n showLabel?: string\n /** Translated aria-label for the reveal button when password is shown. */\n hideLabel?: string\n }\n\n/**\n * Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant \u2014 a\n * trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `\"password\"`\n * (default) and `\"text\"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).\n *\n * Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise\n * the consumer owns the toggle state (useful for \"show password\" master toggle on login screens).\n */\nexport const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(\n (\n {\n className,\n inputClassName,\n size,\n showLockIcon = true,\n revealable = true,\n revealed: revealedProp,\n onRevealedChange,\n showLabel,\n hideLabel,\n disabled,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')\n const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')\n const [internalRevealed, setInternalRevealed] = React.useState(false)\n const isControlled = revealedProp !== undefined\n const revealed = isControlled ? revealedProp! : internalRevealed\n\n const toggleRevealed = React.useCallback(() => {\n const next = !revealed\n if (!isControlled) setInternalRevealed(next)\n onRevealedChange?.(next)\n }, [revealed, isControlled, onRevealedChange])\n\n return (\n <div className={cn(inputWrapperVariants({ size }), className)} data-slot=\"password-input-wrapper\">\n {showLockIcon ? (\n <Lock className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n <input\n ref={ref}\n type={revealed ? 'text' : 'password'}\n disabled={disabled}\n className={cn(inputElementVariants({ size })
|
|
5
|
-
"mappings": ";AAoEM,SAEI,KAFJ;AAlEN,YAAY,WAAW;AACvB,SAAS,KAAK,QAAQ,YAAY;AAElC,SAAS,YAAY;AACrB,SAAS,UAAU;AACnB,SAAS,sBAAsB,4BAA4B;AA8BpD,MAAM,gBAAgB,MAAM;AAAA,EACjC,CACE;AAAA,IACE;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,aAAa;AAAA,IACb,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EACL,GACA,QACG;AACH,UAAM,IAAI,KAAK;AACf,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,CAAC,kBAAkB,mBAAmB,IAAI,MAAM,SAAS,KAAK;AACpE,UAAM,eAAe,iBAAiB;AACtC,UAAM,WAAW,eAAe,eAAgB;AAEhD,UAAM,iBAAiB,MAAM,YAAY,MAAM;AAC7C,YAAM,OAAO,CAAC;AACd,UAAI,CAAC,aAAc,qBAAoB,IAAI;AAC3C,yBAAmB,IAAI;AAAA,IACzB,GAAG,CAAC,UAAU,cAAc,gBAAgB,CAAC;AAE7C,WACE,qBAAC,SAAI,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,SAAS,GAAG,aAAU,0BACtE;AAAA,qBACC,oBAAC,QAAK,WAAU,yCAAwC,eAAY,QAAO,IACzE;AAAA,MACJ;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,WAAW,SAAS;AAAA,UAC1B;AAAA,UACA,WAAW,
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Eye, EyeOff, Lock } from 'lucide-react'\nimport type { VariantProps } from 'class-variance-authority'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { inputWrapperVariants, inputElementVariants } from './input'\n\nexport type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &\n VariantProps<typeof inputWrapperVariants> & {\n /** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */\n showLockIcon?: boolean\n /** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */\n revealable?: boolean\n /** Optional controlled reveal state (otherwise managed internally). */\n revealed?: boolean\n /** Called when the reveal state changes. */\n onRevealedChange?: (next: boolean) => void\n /** Optional className on the wrapper. */\n className?: string\n /** Optional className on the inner `<input>`. */\n inputClassName?: string\n /** Translated aria-label for the reveal button when password is hidden. */\n showLabel?: string\n /** Translated aria-label for the reveal button when password is shown. */\n hideLabel?: string\n }\n\n/**\n * Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant \u2014 a\n * trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `\"password\"`\n * (default) and `\"text\"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).\n *\n * Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise\n * the consumer owns the toggle state (useful for \"show password\" master toggle on login screens).\n */\nexport const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(\n (\n {\n className,\n inputClassName,\n size,\n showLockIcon = true,\n revealable = true,\n revealed: revealedProp,\n onRevealedChange,\n showLabel,\n hideLabel,\n disabled,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')\n const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')\n const [internalRevealed, setInternalRevealed] = React.useState(false)\n const isControlled = revealedProp !== undefined\n const revealed = isControlled ? revealedProp! : internalRevealed\n\n const toggleRevealed = React.useCallback(() => {\n const next = !revealed\n if (!isControlled) setInternalRevealed(next)\n onRevealedChange?.(next)\n }, [revealed, isControlled, onRevealedChange])\n\n return (\n <div className={cn(inputWrapperVariants({ size }), className)} data-slot=\"password-input-wrapper\">\n {showLockIcon ? (\n <Lock className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n <input\n ref={ref}\n type={revealed ? 'text' : 'password'}\n disabled={disabled}\n className={cn(\n inputElementVariants({ size }),\n // Suppress Edge/IE native password reveal + clear controls \u2014 we render our own toggle.\n '[&::-ms-reveal]:hidden [&::-ms-clear]:hidden',\n inputClassName,\n )}\n {...props}\n />\n {revealable ? (\n <button\n type=\"button\"\n onClick={toggleRevealed}\n aria-label={revealed ? resolvedHideLabel : resolvedShowLabel}\n aria-pressed={revealed}\n disabled={disabled}\n className=\"flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {revealed ? (\n <EyeOff className=\"size-4\" aria-hidden=\"true\" />\n ) : (\n <Eye className=\"size-4\" aria-hidden=\"true\" />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nPasswordInput.displayName = 'PasswordInput'\n"],
|
|
5
|
+
"mappings": ";AAoEM,SAEI,KAFJ;AAlEN,YAAY,WAAW;AACvB,SAAS,KAAK,QAAQ,YAAY;AAElC,SAAS,YAAY;AACrB,SAAS,UAAU;AACnB,SAAS,sBAAsB,4BAA4B;AA8BpD,MAAM,gBAAgB,MAAM;AAAA,EACjC,CACE;AAAA,IACE;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,aAAa;AAAA,IACb,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EACL,GACA,QACG;AACH,UAAM,IAAI,KAAK;AACf,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,CAAC,kBAAkB,mBAAmB,IAAI,MAAM,SAAS,KAAK;AACpE,UAAM,eAAe,iBAAiB;AACtC,UAAM,WAAW,eAAe,eAAgB;AAEhD,UAAM,iBAAiB,MAAM,YAAY,MAAM;AAC7C,YAAM,OAAO,CAAC;AACd,UAAI,CAAC,aAAc,qBAAoB,IAAI;AAC3C,yBAAmB,IAAI;AAAA,IACzB,GAAG,CAAC,UAAU,cAAc,gBAAgB,CAAC;AAE7C,WACE,qBAAC,SAAI,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,SAAS,GAAG,aAAU,0BACtE;AAAA,qBACC,oBAAC,QAAK,WAAU,yCAAwC,eAAY,QAAO,IACzE;AAAA,MACJ;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,WAAW,SAAS;AAAA,UAC1B;AAAA,UACA,WAAW;AAAA,YACT,qBAAqB,EAAE,KAAK,CAAC;AAAA;AAAA,YAE7B;AAAA,YACA;AAAA,UACF;AAAA,UACC,GAAG;AAAA;AAAA,MACN;AAAA,MACC,aACC;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS;AAAA,UACT,cAAY,WAAW,oBAAoB;AAAA,UAC3C,gBAAc;AAAA,UACd;AAAA,UACA,WAAU;AAAA,UAET,qBACC,oBAAC,UAAO,WAAU,UAAS,eAAY,QAAO,IAE9C,oBAAC,OAAI,WAAU,UAAS,eAAY,QAAO;AAAA;AAAA,MAE/C,IACE;AAAA,OACN;AAAA,EAEJ;AACF;AACA,cAAc,cAAc;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3778.1.25fdb35f2e",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -132,8 +132,8 @@
|
|
|
132
132
|
"@radix-ui/react-radio-group": "^1.2.3",
|
|
133
133
|
"@radix-ui/react-select": "^2.1.6",
|
|
134
134
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
135
|
-
"@tanstack/react-virtual": "^3.13.
|
|
136
|
-
"date-fns": "4.
|
|
135
|
+
"@tanstack/react-virtual": "^3.13.25",
|
|
136
|
+
"date-fns": "4.3.0",
|
|
137
137
|
"react-big-calendar": "^1.19.4",
|
|
138
138
|
"react-day-picker": "^10.0.1",
|
|
139
139
|
"react-markdown": "^10.1.0",
|
|
@@ -141,25 +141,25 @@
|
|
|
141
141
|
"remark-gfm": "^4.0.1"
|
|
142
142
|
},
|
|
143
143
|
"peerDependencies": {
|
|
144
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
144
|
+
"@open-mercato/shared": "0.6.3-develop.3778.1.25fdb35f2e",
|
|
145
145
|
"react": ">=18.0.0",
|
|
146
146
|
"react-dom": ">=18.0.0",
|
|
147
147
|
"react-is": ">=18.0.0"
|
|
148
148
|
},
|
|
149
149
|
"devDependencies": {
|
|
150
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
150
|
+
"@open-mercato/shared": "0.6.3-develop.3778.1.25fdb35f2e",
|
|
151
151
|
"@testing-library/dom": "^10.4.1",
|
|
152
152
|
"@testing-library/jest-dom": "^6.9.1",
|
|
153
153
|
"@testing-library/react": "^16.3.1",
|
|
154
154
|
"@types/jest": "^30.0.0",
|
|
155
|
-
"@types/react": "^19.2.
|
|
155
|
+
"@types/react": "^19.2.15",
|
|
156
156
|
"@types/react-dom": "^19.2.3",
|
|
157
157
|
"jest": "^30.4.2",
|
|
158
158
|
"jest-environment-jsdom": "^30.4.1",
|
|
159
159
|
"react": "19.2.6",
|
|
160
160
|
"react-dom": "19.2.6",
|
|
161
161
|
"react-is": "^19.2.6",
|
|
162
|
-
"ts-jest": "^29.4.
|
|
162
|
+
"ts-jest": "^29.4.11"
|
|
163
163
|
},
|
|
164
164
|
"publishConfig": {
|
|
165
165
|
"access": "public"
|
|
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'
|
|
|
5
5
|
import { Button } from '../../primitives/button'
|
|
6
6
|
import { apiCall } from '../utils/apiCall'
|
|
7
7
|
import { flash } from '../FlashMessages'
|
|
8
|
-
import { useLastOperation, markUndoSuccess } from './store'
|
|
8
|
+
import { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from './store'
|
|
9
9
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
10
10
|
|
|
11
11
|
export function LastOperationBanner() {
|
|
@@ -14,12 +14,22 @@ export function LastOperationBanner() {
|
|
|
14
14
|
const [pendingToken, setPendingToken] = React.useState<string | null>(null)
|
|
15
15
|
const router = useRouter()
|
|
16
16
|
|
|
17
|
+
const undoToken = operation?.undoToken ?? null
|
|
18
|
+
const isPending = undoToken !== null && pendingToken === undoToken
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (!undoToken || isPending) return
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
dismissOperation(undoToken)
|
|
24
|
+
}, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)
|
|
25
|
+
return () => clearTimeout(timer)
|
|
26
|
+
}, [undoToken, isPending])
|
|
27
|
+
|
|
17
28
|
if (!operation) return null
|
|
18
29
|
|
|
19
30
|
const rawLabel = operation.actionLabel ?? operation.commandId
|
|
20
31
|
const translatedLabel = t(rawLabel)
|
|
21
32
|
const label = translatedLabel === rawLabel ? rawLabel : translatedLabel
|
|
22
|
-
const isPending = pendingToken === operation.undoToken
|
|
23
33
|
|
|
24
34
|
async function handleUndo() {
|
|
25
35
|
const undoToken = operation?.undoToken
|
|
@@ -69,12 +79,12 @@ export function LastOperationBanner() {
|
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
return (
|
|
72
|
-
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-
|
|
82
|
+
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3">
|
|
73
83
|
<div className="min-w-0 truncate">
|
|
74
|
-
<span className="font-medium text-
|
|
84
|
+
<span className="font-medium text-status-warning-text">
|
|
75
85
|
{t('audit_logs.banner.last_operation')}
|
|
76
86
|
</span>
|
|
77
|
-
<span className="ml-2 truncate text-
|
|
87
|
+
<span className="ml-2 truncate text-status-warning-text">
|
|
78
88
|
{label}
|
|
79
89
|
</span>
|
|
80
90
|
</div>
|
|
@@ -83,7 +93,7 @@ export function LastOperationBanner() {
|
|
|
83
93
|
size="sm"
|
|
84
94
|
onClick={() => { void handleUndo() }}
|
|
85
95
|
disabled={isPending}
|
|
86
|
-
className="border-
|
|
96
|
+
className="border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3"
|
|
87
97
|
>
|
|
88
98
|
<Undo2 className="mr-1 size-4" aria-hidden="true" />
|
|
89
99
|
{isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}
|
|
@@ -8,7 +8,7 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
|
|
|
8
8
|
import { LastOperationBanner } from '../LastOperationBanner'
|
|
9
9
|
import { apiCall } from '../../utils/apiCall'
|
|
10
10
|
import { flash } from '../../FlashMessages'
|
|
11
|
-
import { markUndoSuccess, useLastOperation } from '../store'
|
|
11
|
+
import { markUndoSuccess, dismissOperation, useLastOperation } from '../store'
|
|
12
12
|
|
|
13
13
|
jest.setTimeout(20000)
|
|
14
14
|
|
|
@@ -23,6 +23,11 @@ jest.mock('../../FlashMessages', () => ({
|
|
|
23
23
|
jest.mock('../store', () => ({
|
|
24
24
|
useLastOperation: jest.fn(),
|
|
25
25
|
markUndoSuccess: jest.fn(),
|
|
26
|
+
dismissOperation: jest.fn(),
|
|
27
|
+
operationStackConstants: {
|
|
28
|
+
LAST_OPERATION_TTL_MS: 60_000,
|
|
29
|
+
LAST_OPERATION_AUTO_DISMISS_MS: 10_000,
|
|
30
|
+
},
|
|
26
31
|
}))
|
|
27
32
|
|
|
28
33
|
jest.mock('next/navigation', () => ({
|
|
@@ -127,6 +132,83 @@ describe('LastOperationBanner', () => {
|
|
|
127
132
|
expect(markUndoSuccess).toHaveBeenCalledWith(['tk-3'])
|
|
128
133
|
})
|
|
129
134
|
|
|
135
|
+
it('auto-dismisses the banner after the configured timeout when no undo is in flight', () => {
|
|
136
|
+
jest.useFakeTimers()
|
|
137
|
+
try {
|
|
138
|
+
renderWithProviders(<LastOperationBanner />, { dict })
|
|
139
|
+
expect(dismissOperation).not.toHaveBeenCalled()
|
|
140
|
+
jest.advanceTimersByTime(9_999)
|
|
141
|
+
expect(dismissOperation).not.toHaveBeenCalled()
|
|
142
|
+
jest.advanceTimersByTime(1)
|
|
143
|
+
expect(dismissOperation).toHaveBeenCalledTimes(1)
|
|
144
|
+
expect(dismissOperation).toHaveBeenCalledWith('token-123')
|
|
145
|
+
} finally {
|
|
146
|
+
jest.useRealTimers()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('does not auto-dismiss while an undo request is in flight', async () => {
|
|
151
|
+
jest.useFakeTimers()
|
|
152
|
+
try {
|
|
153
|
+
let resolveCall: ((value: unknown) => void) | null = null
|
|
154
|
+
;(apiCall as jest.Mock).mockImplementation(() => new Promise((resolve) => { resolveCall = resolve }))
|
|
155
|
+
|
|
156
|
+
renderWithProviders(<LastOperationBanner />, { dict })
|
|
157
|
+
fireEvent.click(screen.getByRole('button', { name: /undo/i }))
|
|
158
|
+
|
|
159
|
+
jest.advanceTimersByTime(10_000)
|
|
160
|
+
expect(dismissOperation).not.toHaveBeenCalled()
|
|
161
|
+
|
|
162
|
+
resolveCall?.({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
|
|
163
|
+
await Promise.resolve()
|
|
164
|
+
} finally {
|
|
165
|
+
jest.useRealTimers()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('does not fire a spurious dismiss after a successful undo removes the operation', async () => {
|
|
170
|
+
jest.useFakeTimers()
|
|
171
|
+
try {
|
|
172
|
+
let resolveCall: ((value: unknown) => void) | null = null
|
|
173
|
+
;(apiCall as jest.Mock).mockImplementation(() => new Promise((resolve) => { resolveCall = resolve }))
|
|
174
|
+
|
|
175
|
+
const { rerender } = renderWithProviders(<LastOperationBanner />, { dict })
|
|
176
|
+
fireEvent.click(screen.getByRole('button', { name: /undo/i }))
|
|
177
|
+
|
|
178
|
+
resolveCall?.({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
|
|
179
|
+
await Promise.resolve()
|
|
180
|
+
await Promise.resolve()
|
|
181
|
+
await Promise.resolve()
|
|
182
|
+
|
|
183
|
+
;(useLastOperation as jest.Mock).mockReturnValue(null)
|
|
184
|
+
rerender(<LastOperationBanner />)
|
|
185
|
+
|
|
186
|
+
jest.advanceTimersByTime(10_000)
|
|
187
|
+
expect(dismissOperation).not.toHaveBeenCalled()
|
|
188
|
+
} finally {
|
|
189
|
+
jest.useRealTimers()
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('clears the auto-dismiss timer when the operation is replaced before timeout fires', () => {
|
|
194
|
+
jest.useFakeTimers()
|
|
195
|
+
try {
|
|
196
|
+
const { rerender } = renderWithProviders(<LastOperationBanner />, { dict })
|
|
197
|
+
jest.advanceTimersByTime(2_000)
|
|
198
|
+
|
|
199
|
+
;(useLastOperation as jest.Mock).mockReturnValue({ ...mockOperation, undoToken: 'token-456' })
|
|
200
|
+
rerender(<LastOperationBanner />)
|
|
201
|
+
|
|
202
|
+
jest.advanceTimersByTime(9_999)
|
|
203
|
+
expect(dismissOperation).not.toHaveBeenCalled()
|
|
204
|
+
jest.advanceTimersByTime(1)
|
|
205
|
+
expect(dismissOperation).toHaveBeenCalledTimes(1)
|
|
206
|
+
expect(dismissOperation).toHaveBeenCalledWith('token-456')
|
|
207
|
+
} finally {
|
|
208
|
+
jest.useRealTimers()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
130
212
|
it('surfaces undo errors from the API', async () => {
|
|
131
213
|
;(apiCall as jest.Mock).mockResolvedValue({
|
|
132
214
|
ok: false,
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
coalesceLastOperations,
|
|
7
|
+
dismissOperation,
|
|
7
8
|
markUndoSuccess,
|
|
8
9
|
pushOperation,
|
|
9
10
|
clearAllOperations,
|
|
11
|
+
operationStackConstants,
|
|
10
12
|
} from '../store'
|
|
11
13
|
|
|
12
14
|
function makeMeta(id: string, undoToken: string, commandId = 'customers.companies.delete') {
|
|
@@ -140,3 +142,136 @@ describe('markUndoSuccess — bulk-aware', () => {
|
|
|
140
142
|
expect(state.stack).toHaveLength(0)
|
|
141
143
|
})
|
|
142
144
|
})
|
|
145
|
+
|
|
146
|
+
describe('dismissOperation — auto-dismiss without redo pollution', () => {
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
clearAllOperations()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('removes a single entry from the stack without adding it to the undone list', () => {
|
|
152
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
153
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
154
|
+
|
|
155
|
+
dismissOperation('tk-2')
|
|
156
|
+
|
|
157
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
158
|
+
expect(state.stack).toHaveLength(1)
|
|
159
|
+
expect(state.stack[0].undoToken).toBe('tk-1')
|
|
160
|
+
expect(state.undone).toHaveLength(0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('removes a bulk entry from the stack when ALL of its bulkUndoTokens are dismissed', () => {
|
|
164
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
165
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
166
|
+
coalesceLastOperations(2, {
|
|
167
|
+
commandId: 'customers.companies.delete',
|
|
168
|
+
actionLabel: 'Delete 2 companies',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const before = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
172
|
+
const bulkToken = before.stack[0].undoToken as string
|
|
173
|
+
|
|
174
|
+
dismissOperation(bulkToken)
|
|
175
|
+
|
|
176
|
+
const after = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
177
|
+
expect(after.stack.find((entry: { undoToken: string }) => entry.undoToken === bulkToken)).toBeUndefined()
|
|
178
|
+
expect(after.undone).toHaveLength(0)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('splits a bulk entry on partial dismissal without populating undone', () => {
|
|
182
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
183
|
+
pushOperation(makeMeta('op-2', 'tk-2'))
|
|
184
|
+
pushOperation(makeMeta('op-3', 'tk-3'))
|
|
185
|
+
coalesceLastOperations(3, {
|
|
186
|
+
commandId: 'customers.companies.delete',
|
|
187
|
+
actionLabel: 'Delete 3 companies',
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
dismissOperation(['tk-2', 'tk-3'])
|
|
191
|
+
|
|
192
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
193
|
+
expect(state.stack).toHaveLength(1)
|
|
194
|
+
expect(state.stack[0].bulkUndoTokens).toEqual(['tk-1'])
|
|
195
|
+
expect(state.stack[0].bulkCount).toBe(1)
|
|
196
|
+
expect(state.undone).toHaveLength(0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('is a no-op when the token does not match any entry', () => {
|
|
200
|
+
pushOperation(makeMeta('op-1', 'tk-1'))
|
|
201
|
+
|
|
202
|
+
dismissOperation('tk-missing')
|
|
203
|
+
|
|
204
|
+
const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
|
|
205
|
+
expect(state.stack).toHaveLength(1)
|
|
206
|
+
expect(state.stack[0].undoToken).toBe('tk-1')
|
|
207
|
+
expect(state.undone).toHaveLength(0)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('exports the auto-dismiss timeout via operationStackConstants', () => {
|
|
211
|
+
expect(operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBeGreaterThan(0)
|
|
212
|
+
expect(operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)
|
|
213
|
+
.toBeLessThan(operationStackConstants.LAST_OPERATION_TTL_MS)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('LAST_OPERATION_AUTO_DISMISS_MS — env override (NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS)', () => {
|
|
218
|
+
const originalValue = process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
if (originalValue === undefined) {
|
|
222
|
+
delete process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
|
|
223
|
+
} else {
|
|
224
|
+
process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS = originalValue
|
|
225
|
+
}
|
|
226
|
+
jest.resetModules()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
function loadStoreWithEnv(envValue: string | undefined): typeof import('../store') {
|
|
230
|
+
if (envValue === undefined) {
|
|
231
|
+
delete process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
|
|
232
|
+
} else {
|
|
233
|
+
process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS = envValue
|
|
234
|
+
}
|
|
235
|
+
let mod: typeof import('../store') | undefined
|
|
236
|
+
jest.isolateModules(() => {
|
|
237
|
+
mod = jest.requireActual('../store')
|
|
238
|
+
})
|
|
239
|
+
if (!mod) throw new Error('failed to load store module')
|
|
240
|
+
return mod
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
it('defaults to 10_000 ms when the env var is unset', () => {
|
|
244
|
+
const mod = loadStoreWithEnv(undefined)
|
|
245
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('defaults to 10_000 ms when the env var is an empty string', () => {
|
|
249
|
+
const mod = loadStoreWithEnv('')
|
|
250
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('uses the configured env value when it is a positive integer', () => {
|
|
254
|
+
const mod = loadStoreWithEnv('20000')
|
|
255
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(20_000)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('floors a positive non-integer to an integer ms value', () => {
|
|
259
|
+
const mod = loadStoreWithEnv('7500.9')
|
|
260
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(7_500)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('falls back to the default when the env value is non-numeric', () => {
|
|
264
|
+
const mod = loadStoreWithEnv('not-a-number')
|
|
265
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('falls back to the default when the env value is zero', () => {
|
|
269
|
+
const mod = loadStoreWithEnv('0')
|
|
270
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('falls back to the default when the env value is negative', () => {
|
|
274
|
+
const mod = loadStoreWithEnv('-1000')
|
|
275
|
+
expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -22,6 +22,20 @@ const DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }
|
|
|
22
22
|
const STORAGE_KEY = 'om:last-operations:v1'
|
|
23
23
|
const STACK_LIMIT = 20
|
|
24
24
|
const LAST_OPERATION_TTL_MS = 60_000
|
|
25
|
+
const DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 10_000
|
|
26
|
+
|
|
27
|
+
function resolveAutoDismissMs(raw: string | undefined): number {
|
|
28
|
+
if (raw == null || raw === '') return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS
|
|
29
|
+
const parsed = Number(raw)
|
|
30
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
31
|
+
return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS
|
|
32
|
+
}
|
|
33
|
+
return Math.floor(parsed)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(
|
|
37
|
+
process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS,
|
|
38
|
+
)
|
|
25
39
|
const STACK_RETENTION_MS = 10 * 60_000
|
|
26
40
|
|
|
27
41
|
let internalState: OperationStoreState = DEFAULT_STATE
|
|
@@ -200,6 +214,30 @@ export function markUndoSuccess(undoTokens: string | string[]) {
|
|
|
200
214
|
})
|
|
201
215
|
}
|
|
202
216
|
|
|
217
|
+
export function dismissOperation(undoTokens: string | string[]) {
|
|
218
|
+
if (typeof window === 'undefined') return
|
|
219
|
+
const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])
|
|
220
|
+
if (tokenSet.size === 0) return
|
|
221
|
+
updateState((prev) => {
|
|
222
|
+
const nextStack: OperationEntry[] = []
|
|
223
|
+
for (const entry of prev.stack) {
|
|
224
|
+
if (tokenSet.has(entry.undoToken)) continue
|
|
225
|
+
const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null
|
|
226
|
+
if (!bulk) {
|
|
227
|
+
nextStack.push(entry)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
const remaining = bulk.filter((token) => !tokenSet.has(token))
|
|
231
|
+
if (remaining.length === bulk.length) {
|
|
232
|
+
nextStack.push(entry)
|
|
233
|
+
} else if (remaining.length > 0) {
|
|
234
|
+
nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { stack: nextStack, undone: prev.undone }
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
203
241
|
export type CoalesceOptions = {
|
|
204
242
|
commandId?: string
|
|
205
243
|
actionLabel?: string | null
|
|
@@ -296,4 +334,5 @@ export function clearAllOperations() {
|
|
|
296
334
|
|
|
297
335
|
export const operationStackConstants = {
|
|
298
336
|
LAST_OPERATION_TTL_MS,
|
|
337
|
+
LAST_OPERATION_AUTO_DISMISS_MS,
|
|
299
338
|
}
|
|
@@ -90,4 +90,18 @@ describe('PasswordInput primitive', () => {
|
|
|
90
90
|
expect(ref.current).not.toBeNull()
|
|
91
91
|
expect(ref.current?.tagName).toBe('INPUT')
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
it('renders exactly one reveal toggle (suppresses Edge/IE native ::-ms-reveal duplicate)', () => {
|
|
95
|
+
// Regression for issue #2037: Microsoft Edge renders a native password reveal
|
|
96
|
+
// pseudo-element (::-ms-reveal) inside <input type="password">, which appeared
|
|
97
|
+
// alongside our custom toggle as a second eye icon. The fix is a Tailwind arbitrary
|
|
98
|
+
// variant on the inner input that hides ::-ms-reveal (and ::-ms-clear). JSDOM does
|
|
99
|
+
// not render UA pseudo-elements, so we verify the suppression class is present on
|
|
100
|
+
// the input and that our custom button is the sole reveal control.
|
|
101
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
102
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
103
|
+
expect(input.className).toContain('[&::-ms-reveal]:hidden')
|
|
104
|
+
expect(input.className).toContain('[&::-ms-clear]:hidden')
|
|
105
|
+
expect(screen.getAllByRole('button', { name: /show password|hide password/i })).toHaveLength(1)
|
|
106
|
+
})
|
|
93
107
|
})
|
|
@@ -74,7 +74,12 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
|
|
|
74
74
|
ref={ref}
|
|
75
75
|
type={revealed ? 'text' : 'password'}
|
|
76
76
|
disabled={disabled}
|
|
77
|
-
className={cn(
|
|
77
|
+
className={cn(
|
|
78
|
+
inputElementVariants({ size }),
|
|
79
|
+
// Suppress Edge/IE native password reveal + clear controls — we render our own toggle.
|
|
80
|
+
'[&::-ms-reveal]:hidden [&::-ms-clear]:hidden',
|
|
81
|
+
inputClassName,
|
|
82
|
+
)}
|
|
78
83
|
{...props}
|
|
79
84
|
/>
|
|
80
85
|
{revealable ? (
|