portal-mojo 0.2.2 → 0.2.4
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/README.md +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- package/src/ui/toast.tsx +48 -5
|
@@ -18,9 +18,14 @@
|
|
|
18
18
|
// · Controlled on `metadata` (architecture rule 5): the component never
|
|
19
19
|
// mirrors server state. `onSaved` is REQUIRED — it is how the owner
|
|
20
20
|
// (TanStack cache, parent state) learns about the write.
|
|
21
|
+
// · Every save posts the WHOLE blob, so one bad write can wipe a protected
|
|
22
|
+
// key (auth_config / geofence / redemption_policy) in a single POST.
|
|
23
|
+
// Removing a top-level key therefore stops on a guardrail that lists
|
|
24
|
+
// the keys about to vanish; owners add `beforeSave(next, prev)` to diff
|
|
25
|
+
// their own protected keys on top.
|
|
21
26
|
import { useState } from 'react';
|
|
22
27
|
import { mojoCall } from '../../client/client';
|
|
23
|
-
import {
|
|
28
|
+
import { confirmGuardrail } from '../guardrail';
|
|
24
29
|
import { toast } from '../toast';
|
|
25
30
|
|
|
26
31
|
type Draft =
|
|
@@ -49,13 +54,42 @@ function parseValue(raw: string): unknown {
|
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Default stop: a top-level key is about to leave the blob. Names every key
|
|
59
|
+
* so a wipe of `auth_config` reads as exactly that, not "Remove entry?".
|
|
60
|
+
*/
|
|
61
|
+
function confirmRemovedKeys(removed: string[], next: Record<string, unknown>): Promise<boolean> {
|
|
62
|
+
const one = removed.length === 1;
|
|
63
|
+
const remaining = Object.keys(next).length;
|
|
64
|
+
return confirmGuardrail({
|
|
65
|
+
title: one ? `Remove metadata key ${removed[0]}?` : `Remove ${removed.length} metadata keys?`,
|
|
66
|
+
effect: <>
|
|
67
|
+
{removed.map((key, i) => <span key={key}>{i > 0 ? ', ' : ''}<code>{key}</code></span>)}
|
|
68
|
+
{one ? ' leaves' : ' leave'} the record the moment this saves — the POST carries the whole blob, {remaining === 0 ? 'and it will be empty' : `${remaining} key${remaining === 1 ? '' : 's'} remain`}.
|
|
69
|
+
</>,
|
|
70
|
+
why: [
|
|
71
|
+
<>Anything that reads {one ? 'this key' : 'these keys'} (policy, auth configuration, geofences, redemption rules) sees it missing on its next read, with no error raised here.</>,
|
|
72
|
+
<>The value is not kept anywhere in this portal — re-adding the key means re-entering it by hand.</>,
|
|
73
|
+
],
|
|
74
|
+
undo: 'There is no undo; re-add the key with its previous value.',
|
|
75
|
+
confirmText: one ? 'Remove key' : `Remove ${removed.length} keys`,
|
|
76
|
+
typeToConfirm: removed.length > 1 ? String(removed.length) : undefined,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function MetadataSection({ endpoint, id, metadata, onSaved, beforeSave, title = 'Metadata' }: {
|
|
53
81
|
/** Collection endpoint — the save posts to `<endpoint>/<id>`. */
|
|
54
82
|
endpoint: string;
|
|
55
83
|
id: number | string;
|
|
56
84
|
metadata: Record<string, unknown>;
|
|
57
85
|
/** Called with the new blob after the server accepted it. */
|
|
58
86
|
onSaved: (next: Record<string, unknown>) => void;
|
|
87
|
+
/**
|
|
88
|
+
* Owner gate, after the built-in removed-key stop: resolve false to
|
|
89
|
+
* cancel the write (no error, editor state kept). Diff protected keys
|
|
90
|
+
* between `next` and `prev` here.
|
|
91
|
+
*/
|
|
92
|
+
beforeSave?: (next: Record<string, unknown>, prev: Record<string, unknown>) => Promise<boolean> | boolean;
|
|
59
93
|
title?: string;
|
|
60
94
|
}) {
|
|
61
95
|
const [draft, setDraft] = useState<Draft>(null);
|
|
@@ -65,8 +99,16 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
|
|
|
65
99
|
const keys = Object.keys(metadata).sort();
|
|
66
100
|
const busy = pending !== null;
|
|
67
101
|
|
|
68
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The one write path. Resolves true only when the server accepted it;
|
|
104
|
+
* false when a gate declined (nothing posted, no error) or the POST
|
|
105
|
+
* rejected (banner). Gates run BEFORE pending so the dialog is not
|
|
106
|
+
* stacked over disabled controls.
|
|
107
|
+
*/
|
|
69
108
|
async function commit(next: Record<string, unknown>, token: string, message: string): Promise<boolean> {
|
|
109
|
+
const removed = Object.keys(metadata).filter((key) => !Object.prototype.hasOwnProperty.call(next, key));
|
|
110
|
+
if (removed.length > 0 && !(await confirmRemovedKeys(removed, next))) return false;
|
|
111
|
+
if (beforeSave && !(await beforeSave(next, metadata))) return false;
|
|
70
112
|
setPending(token);
|
|
71
113
|
setError(null);
|
|
72
114
|
try {
|
|
@@ -101,13 +143,7 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
|
|
|
101
143
|
}
|
|
102
144
|
|
|
103
145
|
async function removeKey(key: string) {
|
|
104
|
-
|
|
105
|
-
title: 'Remove entry',
|
|
106
|
-
message: <>Remove metadata key <b>{key}</b>?</>,
|
|
107
|
-
confirmText: 'Remove',
|
|
108
|
-
danger: true,
|
|
109
|
-
});
|
|
110
|
-
if (!confirmed) return;
|
|
146
|
+
// The removed-key guardrail inside commit() is the confirm.
|
|
111
147
|
const next = { ...metadata };
|
|
112
148
|
delete next[key];
|
|
113
149
|
await commit(next, `delete:${key}`, 'Metadata entry removed');
|
package/src/ui/form-autosave.ts
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
// · zod failure: the save of that field is blocked client-side; the message
|
|
21
21
|
// occupies the same error slot. Hidden (showWhen) fields never queue and
|
|
22
22
|
// their transient state clears.
|
|
23
|
+
// · beforeSave (optional): runs once per batch BEFORE the POST, with the
|
|
24
|
+
// wire body. While it awaits the batch counts as in flight (the timer
|
|
25
|
+
// cannot re-fire; a commit meanwhile joins the NEXT batch). `false`
|
|
26
|
+
// DROPS the batch: its names revert to the server snapshot and go idle
|
|
27
|
+
// — no error, no toast (a guardrail the operator declined is not a
|
|
28
|
+
// failure). An object REPLACES the body. true/undefined proceeds.
|
|
23
29
|
//
|
|
24
30
|
// Shape: ALL machine state lives in ONE reducer (draft values, server
|
|
25
31
|
// snapshot, per-field status, the pending batch, the in-flight flag) so every
|
|
@@ -168,6 +174,8 @@ export type AutosaveAction =
|
|
|
168
174
|
| { type: 'SAVE_OK'; names: string[]; sent: Record<string, FieldValue>; server: FieldValues }
|
|
169
175
|
/** Batch rejected: REVERT names to the snapshot, pin the server message. */
|
|
170
176
|
| { type: 'SAVE_FAIL'; names: string[]; error: string }
|
|
177
|
+
/** beforeSave vetoed: REVERT names to the snapshot, status idle, no error. */
|
|
178
|
+
| { type: 'BATCH_DROP'; names: string[] }
|
|
171
179
|
/** The ~1.5s saved flash ended. */
|
|
172
180
|
| { type: 'FLASH_DONE'; name: string }
|
|
173
181
|
/** The row prop changed (refetch/invalidate) or fields arrived late. */
|
|
@@ -366,6 +374,22 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
|
|
|
366
374
|
return { ...state, draft, pending, fieldState, fieldError, inflight: false };
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
case 'BATCH_DROP': {
|
|
378
|
+
const draft = { ...state.draft };
|
|
379
|
+
const fieldState = { ...state.fieldState };
|
|
380
|
+
const fieldError = { ...state.fieldError };
|
|
381
|
+
for (const n of action.names) {
|
|
382
|
+
// A field re-committed while beforeSave awaited belongs to the
|
|
383
|
+
// NEXT batch (SAVE_OK precedent) — the veto covers only what
|
|
384
|
+
// this batch carried.
|
|
385
|
+
if (n in state.pending) continue;
|
|
386
|
+
if (n in state.server) draft[n] = state.server[n]!;
|
|
387
|
+
fieldState[n] = 'idle';
|
|
388
|
+
fieldError[n] = null;
|
|
389
|
+
}
|
|
390
|
+
return { ...state, draft, fieldState, fieldError, inflight: false };
|
|
391
|
+
}
|
|
392
|
+
|
|
369
393
|
case 'FLASH_DONE': {
|
|
370
394
|
if (state.fieldState[action.name] !== 'saved') return state;
|
|
371
395
|
return { ...state, fieldState: { ...state.fieldState, [action.name]: 'idle' } };
|
|
@@ -387,6 +411,83 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
|
|
|
387
411
|
}
|
|
388
412
|
}
|
|
389
413
|
|
|
414
|
+
// ── The batch runner ──────────────────────────────────────────────────
|
|
415
|
+
// Pure orchestration over the reducer — exported so the contract (veto /
|
|
416
|
+
// amend / concurrent commit / no-hook parity) is unit-testable without a
|
|
417
|
+
// DOM (scripts/verify-form-autosave.mjs). The hook binds it to its refs.
|
|
418
|
+
|
|
419
|
+
export interface AutosaveBatchRunnerDeps<T> {
|
|
420
|
+
/** The state snapshot at fire time. */
|
|
421
|
+
state: AutosaveState;
|
|
422
|
+
/** Current options, captured once for this batch and its settlement. */
|
|
423
|
+
opts: () => Pick<UseFormAutosaveOptions<T>, 'fields' | 'row' | 'save' | 'beforeSave' | 'onSaved' | 'onSaveError'>;
|
|
424
|
+
dispatch: (action: AutosaveAction) => void;
|
|
425
|
+
/** Re-arm the batch window (drain anything committed mid-flight). */
|
|
426
|
+
armBatchTimer: () => void;
|
|
427
|
+
/** Start a field's saved flash. */
|
|
428
|
+
armFlashTimer: (name: string) => void;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Fire one batch from `state.pending`. Resolves when the batch has settled
|
|
433
|
+
* (saved, failed, or dropped); resolves immediately when nothing fires
|
|
434
|
+
* (in flight, or nothing pending).
|
|
435
|
+
*/
|
|
436
|
+
export function runAutosaveBatch<T>(deps: AutosaveBatchRunnerDeps<T>): Promise<void> {
|
|
437
|
+
const { state: s, opts, dispatch, armBatchTimer, armFlashTimer } = deps;
|
|
438
|
+
if (s.inflight) return Promise.resolve(); // completion re-arms the timer (serialize)
|
|
439
|
+
const names = Object.keys(s.pending);
|
|
440
|
+
if (names.length === 0) return Promise.resolve();
|
|
441
|
+
const sent: Record<string, FieldValue> = { ...s.pending };
|
|
442
|
+
const changes = expandDotted(sent);
|
|
443
|
+
// The approval, write target and callbacks belong to the same batch.
|
|
444
|
+
// Reading opts again after a gate can redirect an approved write to a
|
|
445
|
+
// different row/model rendered while the confirmation was open.
|
|
446
|
+
const batch = opts();
|
|
447
|
+
dispatch({ type: 'BATCH_START', names });
|
|
448
|
+
|
|
449
|
+
const post = (body: Record<string, unknown>): Promise<void> => batch.save(body).then(
|
|
450
|
+
(savedRow) => {
|
|
451
|
+
const server = serverValuesFor(batch.fields, savedRow);
|
|
452
|
+
dispatch({ type: 'SAVE_OK', names, sent, server });
|
|
453
|
+
for (const n of names) armFlashTimer(n);
|
|
454
|
+
batch.onSaved?.({ changes: body, fields: names, row: savedRow });
|
|
455
|
+
armBatchTimer(); // drain anything committed mid-flight
|
|
456
|
+
},
|
|
457
|
+
(err: unknown) => fail(body, err),
|
|
458
|
+
);
|
|
459
|
+
const fail = (body: Record<string, unknown>, err: unknown) => {
|
|
460
|
+
const error = err instanceof Error ? err : new Error('Save failed');
|
|
461
|
+
dispatch({ type: 'SAVE_FAIL', names, error: error.message });
|
|
462
|
+
batch.onSaveError?.({ changes: body, fields: names, error });
|
|
463
|
+
armBatchTimer();
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const beforeSave = batch.beforeSave;
|
|
467
|
+
if (!beforeSave) return post(changes); // no hook: the pre-existing path, unchanged
|
|
468
|
+
|
|
469
|
+
// The gate. BATCH_START already flagged inflight, so the window cannot
|
|
470
|
+
// re-fire while this awaits and commits meanwhile queue the NEXT batch.
|
|
471
|
+
let verdict: Promise<boolean | void | Record<string, unknown>>;
|
|
472
|
+
try {
|
|
473
|
+
verdict = Promise.resolve(beforeSave({ changes, names, row: batch.row }));
|
|
474
|
+
} catch (err) {
|
|
475
|
+
verdict = Promise.reject(err);
|
|
476
|
+
}
|
|
477
|
+
return verdict.then(
|
|
478
|
+
(v) => {
|
|
479
|
+
if (v === false) {
|
|
480
|
+
dispatch({ type: 'BATCH_DROP', names });
|
|
481
|
+
armBatchTimer(); // a commit made during the await fires next
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const body = v !== null && typeof v === 'object' ? v : changes;
|
|
485
|
+
return post(body);
|
|
486
|
+
},
|
|
487
|
+
(err: unknown) => fail(changes, err),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
390
491
|
// ── The hook ──────────────────────────────────────────────────────────
|
|
391
492
|
|
|
392
493
|
export interface AutosaveBatchInfo {
|
|
@@ -396,6 +497,24 @@ export interface AutosaveBatchInfo {
|
|
|
396
497
|
fields: string[];
|
|
397
498
|
}
|
|
398
499
|
|
|
500
|
+
export interface AutosaveBeforeSaveInfo<T> {
|
|
501
|
+
/** The wire body about to POST (dotted names expanded). */
|
|
502
|
+
changes: Record<string, unknown>;
|
|
503
|
+
/** Flat field names the batch carries (`permissions.manage_group`, …). */
|
|
504
|
+
names: string[];
|
|
505
|
+
/** The server row the batch edits. */
|
|
506
|
+
row: T;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Pre-save gate. `false` drops the batch (fields revert to server values,
|
|
511
|
+
* status → idle, no error toast); an object replaces `changes`; `true` /
|
|
512
|
+
* `undefined` proceeds. A thrown/rejected hook is a bug, not a veto — it is
|
|
513
|
+
* surfaced like a failed save (revert + pinned error + onSaveError).
|
|
514
|
+
*/
|
|
515
|
+
export type AutosaveBeforeSave<T> = (info: AutosaveBeforeSaveInfo<T>) =>
|
|
516
|
+
boolean | void | Record<string, unknown> | Promise<boolean | void | Record<string, unknown>>;
|
|
517
|
+
|
|
399
518
|
export interface UseFormAutosaveOptions<T> {
|
|
400
519
|
/** EVERY saveable field — flat fields plus every tab's fields. */
|
|
401
520
|
fields: Field[];
|
|
@@ -403,6 +522,14 @@ export interface UseFormAutosaveOptions<T> {
|
|
|
403
522
|
row: T;
|
|
404
523
|
/** One batch POST. MUST reject on failure (defineModel useSave contract). */
|
|
405
524
|
save: (changes: Record<string, unknown>) => Promise<T>;
|
|
525
|
+
/**
|
|
526
|
+
* Runs before a batch POSTs. Return false to drop the batch (fields
|
|
527
|
+
* revert to server values, status → idle, no error toast); return an
|
|
528
|
+
* object to replace `changes`; true/undefined to proceed. While it awaits
|
|
529
|
+
* the batch is in flight: the timer cannot re-fire and a commit meanwhile
|
|
530
|
+
* joins the NEXT batch.
|
|
531
|
+
*/
|
|
532
|
+
beforeSave?: AutosaveBeforeSave<T>;
|
|
406
533
|
/** Batch window after the last commit (web-mojo: 300). */
|
|
407
534
|
debounceMs?: number;
|
|
408
535
|
/** How long the 'saved' check shows before returning to idle. */
|
|
@@ -485,29 +612,13 @@ export function useFormAutosave<T>(opts: UseFormAutosaveOptions<T>): FormAutosav
|
|
|
485
612
|
|
|
486
613
|
// ── the batch runner ──────────────────────────────────────────────
|
|
487
614
|
const fireBatch = useCallback(() => {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
optsRef.current.save(changes).then(
|
|
497
|
-
(savedRow) => {
|
|
498
|
-
const server = serverValuesFor(optsRef.current.fields, savedRow);
|
|
499
|
-
dispatch({ type: 'SAVE_OK', names, sent, server });
|
|
500
|
-
for (const n of names) armFlashTimer(n);
|
|
501
|
-
optsRef.current.onSaved?.({ changes, fields: names, row: savedRow });
|
|
502
|
-
armBatchTimer(); // drain anything committed mid-flight
|
|
503
|
-
},
|
|
504
|
-
(err: unknown) => {
|
|
505
|
-
const error = err instanceof Error ? err : new Error('Save failed');
|
|
506
|
-
dispatch({ type: 'SAVE_FAIL', names, error: error.message });
|
|
507
|
-
optsRef.current.onSaveError?.({ changes, fields: names, error });
|
|
508
|
-
armBatchTimer();
|
|
509
|
-
},
|
|
510
|
-
);
|
|
615
|
+
void runAutosaveBatch<T>({
|
|
616
|
+
state: stateRef.current,
|
|
617
|
+
opts: () => optsRef.current,
|
|
618
|
+
dispatch,
|
|
619
|
+
armBatchTimer,
|
|
620
|
+
armFlashTimer,
|
|
621
|
+
});
|
|
511
622
|
}, [armBatchTimer, armFlashTimer]);
|
|
512
623
|
fireBatchRef.current = fireBatch;
|
|
513
624
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Guardrail — the "are you sure, and here is WHY" stop in front of a change
|
|
2
|
+
// that takes a tenant dark, moves money, or cannot be undone if made by
|
|
3
|
+
// accident. One shape for every breaking knob so the operator learns to read
|
|
4
|
+
// it: what happens the moment it lands, why that is dangerous (concrete
|
|
5
|
+
// consequences, not adjectives), how to get back — and, for the sharpest
|
|
6
|
+
// knobs, a typed literal before the button arms.
|
|
7
|
+
//
|
|
8
|
+
// Promoted from wmx-admin-v2 `src/lib/guardrail.tsx` (bc3e900) verbatim in
|
|
9
|
+
// contract and markup so the app's copy keeps working when it swaps the
|
|
10
|
+
// import. Built on the native-<dialog> `modal.open` stack, so it stacks
|
|
11
|
+
// correctly over a formModal that is still open.
|
|
12
|
+
//
|
|
13
|
+
// Which stop to reach for:
|
|
14
|
+
// · `modal.confirm` — reversible / low blast radius; a yes-no.
|
|
15
|
+
// · `ArmedButton` — irreversible, needs no input, inline (row actions).
|
|
16
|
+
// · `confirmGuardrail` — anything that takes a tenant dark, moves money, or
|
|
17
|
+
// can't be undone — the operator must read WHY.
|
|
18
|
+
//
|
|
19
|
+
// Usage — `if (!(await confirmGuardrail({...}))) return;` before the mutation.
|
|
20
|
+
// The dialog resolves false on Escape / backdrop / Cancel, true only on the
|
|
21
|
+
// armed confirm button.
|
|
22
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
23
|
+
import { modal } from './modal';
|
|
24
|
+
|
|
25
|
+
export interface Guardrail {
|
|
26
|
+
/** Question form, naming the target: "Disable Alea for Club Axo?" */
|
|
27
|
+
title: string;
|
|
28
|
+
/** What happens the moment this lands — present tense, one or two sentences. */
|
|
29
|
+
effect: ReactNode;
|
|
30
|
+
/** Why it is dangerous — each item one concrete consequence. */
|
|
31
|
+
why: ReactNode[];
|
|
32
|
+
/** How to get back, or that you can't. */
|
|
33
|
+
undo?: ReactNode;
|
|
34
|
+
confirmText: string;
|
|
35
|
+
cancelText?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Highest tier: the operator must type this literal (the brand name, a
|
|
38
|
+
* count, "DISABLE") before the confirm button arms. Compared trimmed and
|
|
39
|
+
* case-insensitively.
|
|
40
|
+
*/
|
|
41
|
+
typeToConfirm?: string;
|
|
42
|
+
/** false = primary button + warn tint for a stop that is not destructive. Default true. */
|
|
43
|
+
danger?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function GuardrailBody({ g, close }: { g: Guardrail; close: (v: boolean) => void }) {
|
|
47
|
+
const [typed, setTyped] = useState('');
|
|
48
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
49
|
+
const needsTyping = Boolean(g.typeToConfirm);
|
|
50
|
+
const armed = !needsTyping || typed.trim().toLowerCase() === g.typeToConfirm!.trim().toLowerCase();
|
|
51
|
+
const danger = g.danger !== false;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (needsTyping) inputRef.current?.focus();
|
|
55
|
+
}, [needsTyping]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={danger ? 'modal-pad guardrail' : 'modal-pad guardrail guardrail-warn'}>
|
|
59
|
+
<h2 className="modal-title">
|
|
60
|
+
<i className={`bi ${danger ? 'bi-exclamation-octagon-fill' : 'bi-exclamation-triangle-fill'} guardrail-icon`} /> {g.title}
|
|
61
|
+
</h2>
|
|
62
|
+
<div className="modal-message">
|
|
63
|
+
<p className="guardrail-effect">{g.effect}</p>
|
|
64
|
+
<div className="guardrail-why">
|
|
65
|
+
<div className="guardrail-why-head">Why this is dangerous</div>
|
|
66
|
+
<ul>
|
|
67
|
+
{g.why.map((w, i) => <li key={i}>{w}</li>)}
|
|
68
|
+
</ul>
|
|
69
|
+
</div>
|
|
70
|
+
{g.undo && (
|
|
71
|
+
<p className="guardrail-undo"><i className="bi bi-arrow-counterclockwise" /> {g.undo}</p>
|
|
72
|
+
)}
|
|
73
|
+
{needsTyping && (
|
|
74
|
+
<label className="guardrail-type">
|
|
75
|
+
<span>Type <b>{g.typeToConfirm}</b> to confirm</span>
|
|
76
|
+
<input
|
|
77
|
+
ref={inputRef}
|
|
78
|
+
className="input"
|
|
79
|
+
value={typed}
|
|
80
|
+
autoComplete="off"
|
|
81
|
+
spellCheck={false}
|
|
82
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
83
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && armed) close(true); }}
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
<div className="modal-actions">
|
|
89
|
+
<button type="button" className="btn" onClick={() => close(false)}>{g.cancelText ?? 'Cancel'}</button>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
|
|
93
|
+
disabled={!armed}
|
|
94
|
+
onClick={() => close(true)}
|
|
95
|
+
>
|
|
96
|
+
{g.confirmText}
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Open the guardrail and resolve true only when the operator confirms. */
|
|
104
|
+
export function confirmGuardrail(g: Guardrail): Promise<boolean> {
|
|
105
|
+
return modal.open<boolean>((close) => <GuardrailBody g={g} close={close} />, { size: 'sm' })
|
|
106
|
+
.then((v) => v === true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Diff helper for config-form saves: which of the watched keys changed
|
|
111
|
+
* between what the form was seeded with and what came back. Lets a save
|
|
112
|
+
* confirm ONLY the fields that break things (environment, currency codes…)
|
|
113
|
+
* and pass silently otherwise.
|
|
114
|
+
*/
|
|
115
|
+
export function changedKeys(
|
|
116
|
+
initial: Record<string, unknown>,
|
|
117
|
+
next: Record<string, unknown>,
|
|
118
|
+
watch: readonly string[],
|
|
119
|
+
): string[] {
|
|
120
|
+
return watch.filter((k) => {
|
|
121
|
+
const a = initial[k]; const b = next[k];
|
|
122
|
+
const norm = (v: unknown) => (v == null || v === '' ? '' : String(v));
|
|
123
|
+
return norm(a) !== norm(b);
|
|
124
|
+
});
|
|
125
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './RequiresGroup';
|
|
|
9
9
|
export * from './menu-registry';
|
|
10
10
|
export * from './SidebarNav';
|
|
11
11
|
export * from './armed-button';
|
|
12
|
+
export * from './guardrail';
|
|
12
13
|
export * from './loading';
|
|
13
14
|
export * from './CollectionMultiSelect';
|
|
14
15
|
export * from './CollectionSelect';
|
package/src/ui/modal.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// native <dialog> + a portal-free host. `await modal.confirm(...)` from any
|
|
3
3
|
// event handler; no JSX modal state to hoist. The z-index/backdrop stack
|
|
4
4
|
// manager web-mojo needed does not exist here: <dialog> stacks natively.
|
|
5
|
-
import {
|
|
5
|
+
import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
|
|
6
6
|
|
|
7
7
|
export type ModalSize = 'sm' | 'md' | 'lg';
|
|
8
8
|
|
|
@@ -200,8 +200,11 @@ export const modal = {
|
|
|
200
200
|
function ModalDialog({ item }: { item: ModalItem }) {
|
|
201
201
|
const ref = useRef<HTMLDialogElement>(null);
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
ref.current
|
|
203
|
+
useLayoutEffect(() => {
|
|
204
|
+
const dialog = ref.current;
|
|
205
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
206
|
+
dialog?.showModal();
|
|
207
|
+
return () => { if (dialog?.open) dialog.close(); if (opener?.isConnected) opener.focus(); };
|
|
205
208
|
}, []);
|
|
206
209
|
|
|
207
210
|
const dismiss = () => {
|
|
@@ -216,6 +219,7 @@ function ModalDialog({ item }: { item: ModalItem }) {
|
|
|
216
219
|
onCancel={(e) => { e.preventDefault(); dismiss(); }}
|
|
217
220
|
onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
|
|
218
221
|
>
|
|
222
|
+
{item.flush && <button type="button" className="btn-icon modal-fallback-close" aria-label="Close" onClick={dismiss}><i className="bi bi-x-lg" /></button>}
|
|
219
223
|
{item.render(item.resolve)}
|
|
220
224
|
</dialog>
|
|
221
225
|
);
|
|
@@ -224,7 +228,7 @@ function ModalDialog({ item }: { item: ModalItem }) {
|
|
|
224
228
|
function DrawerDialog({ item }: { item: DrawerItem }) {
|
|
225
229
|
const ref = useRef<HTMLDialogElement>(null);
|
|
226
230
|
|
|
227
|
-
|
|
231
|
+
useLayoutEffect(() => {
|
|
228
232
|
const el = ref.current;
|
|
229
233
|
el?.showModal();
|
|
230
234
|
// Unmount is the END of the exit animation (the manager holds the item
|
package/src/ui/toast.tsx
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// Visual spec from web-mojo's toast.css (left accent bar, compact card,
|
|
19
19
|
// bottom-right); idiom styles live in the app's theme/idioms.css — tokens
|
|
20
20
|
// only, both themes.
|
|
21
|
-
import { useSyncExternalStore } from 'react';
|
|
21
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
22
|
+
import { createPortal } from 'react-dom';
|
|
22
23
|
|
|
23
24
|
type Level = 'success' | 'error' | 'info' | 'warning';
|
|
24
25
|
|
|
@@ -187,8 +188,50 @@ export function ToastHost() {
|
|
|
187
188
|
(cb) => { listeners.add(cb); return () => listeners.delete(cb); },
|
|
188
189
|
() => toasts,
|
|
189
190
|
);
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
// Keep one portal target so moving above a new modal preserves live cards.
|
|
192
|
+
const [host] = useState(() => typeof document === 'undefined' ? null : document.createElement('div'));
|
|
193
|
+
const visible = useRef(false);
|
|
194
|
+
visible.current = items.length > 0;
|
|
195
|
+
const syncHost = useRef<() => void>(() => {});
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
if (!host) return;
|
|
198
|
+
host.className = 'toast-host';
|
|
199
|
+
host.setAttribute('role', 'status');
|
|
200
|
+
host.setAttribute('aria-live', 'polite');
|
|
201
|
+
const supportsPopover = typeof host.showPopover === 'function';
|
|
202
|
+
if (supportsPopover) host.setAttribute('popover', 'manual');
|
|
203
|
+
let dialogs: HTMLDialogElement[] = [];
|
|
204
|
+
const sync = (records: MutationRecord[] = []) => {
|
|
205
|
+
const open = Array.from(document.querySelectorAll('dialog')).filter(dialog => dialog.matches(':modal'));
|
|
206
|
+
dialogs = dialogs.filter(dialog => open.includes(dialog));
|
|
207
|
+
for (const dialog of open) if (!dialogs.includes(dialog)) dialogs.push(dialog);
|
|
208
|
+
// Opening order, not DOM order, determines the active native modal.
|
|
209
|
+
for (const record of records) {
|
|
210
|
+
if (record.type !== 'attributes' || record.oldValue !== null || !(record.target instanceof HTMLDialogElement) || !open.includes(record.target)) continue;
|
|
211
|
+
dialogs = [...dialogs.filter(dialog => dialog !== record.target), record.target];
|
|
212
|
+
}
|
|
213
|
+
const parent = dialogs.at(-1) ?? document.body;
|
|
214
|
+
const moved = host.parentElement !== parent;
|
|
215
|
+
if (supportsPopover && host.matches(':popover-open') && (moved || !visible.current)) host.hidePopover();
|
|
216
|
+
// A body-level popover paints above dialogs but remains inert.
|
|
217
|
+
// Modal-local ancestry is required for Undo/Cancel and keyboard use.
|
|
218
|
+
if (moved) parent.appendChild(host);
|
|
219
|
+
if (supportsPopover && visible.current && !host.matches(':popover-open')) host.showPopover();
|
|
220
|
+
};
|
|
221
|
+
syncHost.current = sync;
|
|
222
|
+
const observer = new MutationObserver(sync);
|
|
223
|
+
observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'], attributeOldValue: true });
|
|
224
|
+
sync();
|
|
225
|
+
return () => {
|
|
226
|
+
observer.disconnect();
|
|
227
|
+
syncHost.current = () => {};
|
|
228
|
+
if (supportsPopover && host.matches(':popover-open')) host.hidePopover();
|
|
229
|
+
host.remove();
|
|
230
|
+
};
|
|
231
|
+
}, [host]);
|
|
232
|
+
useLayoutEffect(() => { syncHost.current(); }, [items.length > 0]);
|
|
233
|
+
return host ? createPortal(
|
|
234
|
+
<>
|
|
192
235
|
{items.map((t) => {
|
|
193
236
|
if (t.kind === 'basic') {
|
|
194
237
|
return (
|
|
@@ -214,6 +257,6 @@ export function ToastHost() {
|
|
|
214
257
|
}
|
|
215
258
|
return <ProgressCard key={t.id} t={t} />;
|
|
216
259
|
})}
|
|
217
|
-
|
|
218
|
-
);
|
|
260
|
+
</>, host,
|
|
261
|
+
) : null;
|
|
219
262
|
}
|