Skip to content
29 changes: 13 additions & 16 deletions dashboard/src/workspaces/settings/EffectiveConfigTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* one row per served scalar, grouped under sticky section rows.
*
* Hover and focus INSPECT a row: the inspector shows its exact evidence and
* nothing on the row changes. Click, Enter, or Space SELECT it: the review
* state opens directly under it. Escape closes the review, then clears the
* nothing on the row changes. Click, Enter, or Space SELECT it: the editor
* opens directly under it. Escape closes the editor, then clears the
* inspection. Arrow keys rove between rows so a reader never has to Tab through
* fifty keys to reach the one they want.
*
Expand All @@ -30,7 +30,7 @@ import type { SettingsEditorHandle } from './SettingsEditorController.tsx';
import type { SettingsEditorState } from './settingsEditorMachine.ts';
import type { WritableScopes } from './settingsGates.ts';
import type { ConfigSection } from './settingsModel.ts';
import { SettingsReviewPanel } from './SettingsReviewPanel.tsx';
import { SettingsEditorPanel } from './SettingsEditorPanel.tsx';
import {
bindingFor,
draftValue,
Expand Down Expand Up @@ -67,15 +67,12 @@ const COLUMNS =
'@min-[41rem]:grid-cols-[minmax(9rem,1.3fr)_minmax(8rem,1.2fr)_6.5rem_minmax(9.5rem,1.2fr)_4.5rem]';
const COLUMN_GAP = 'gap-x-2';

/** The machine states in which a frozen review, and its controls, are on screen. */
const REVIEW_BEARING: ReadonlySet<SettingsEditorState['status']> = new Set([
'reviewing',
'confirmed',
/** States whose save controls can resolve back to the editor. */
const SAVE_BEARING: ReadonlySet<SettingsEditorState['status']> = new Set([
'submitting',
'conflicted',
'authority_withdrawn',
'submit_failed',
'review_superseded',
]);

export function EffectiveConfigTable({
Expand Down Expand Up @@ -117,17 +114,17 @@ export function EffectiveConfigTable({
// hide the verdict the reader is waiting for.
const pinned = editor.state.status === 'submitting';

// Focus returns to the edited row when a frozen review resolves and the
// control the reader was on, Apply, Retry, Load current values, unmounts
// with the review stage. Only when focus was actually lost to the document:
// a keystroke in another scope's input also ends a staged review, and that
// Focus returns to the edited row when a save resolves and the control the
// reader was on, Retry or Load current values, unmounts. Only when focus was
// actually lost to the document:
// a keystroke in another scope's input also ends a pending save, and that
// input must keep its focus.
const status = editor.state.status;
const previousStatus = useRef(status);
useEffect(() => {
const wasStaged = REVIEW_BEARING.has(previousStatus.current);
const wasSaving = SAVE_BEARING.has(previousStatus.current);
previousStatus.current = status;
if (!wasStaged || status !== 'editing' || selectedKey === null) return;
if (!wasSaving || status !== 'editing' || selectedKey === null) return;
const active = document.activeElement;
const container = scrollRef.current;
if (active === null || active === document.body || !container?.contains(active)) {
Expand All @@ -151,7 +148,7 @@ export function EffectiveConfigTable({
}
return;
}
// Rove only from a row; a key pressed inside the review panel's inputs
// Rove only from a row; a key pressed inside the editor's inputs
// belongs to the input.
if (!(event.target instanceof HTMLElement) || event.target.getAttribute('role') !== 'row') {
return;
Expand Down Expand Up @@ -236,7 +233,7 @@ export function EffectiveConfigTable({
{selected ? (
<div role="row" aria-selected={false}>
<div role="gridcell" aria-colspan={5}>
<SettingsReviewPanel
<SettingsEditorPanel
row={row}
capability={capability}
editor={editor}
Expand Down
73 changes: 38 additions & 35 deletions dashboard/src/workspaces/settings/SettingsEditorController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@
* issued from.
*
* It holds no state of its own beyond the machine. The authority is whatever
* the last parsed read produced, the draft and the review live in the machine,
* and a request exists only where the machine has reached `submitting`, which
* it only does after re-deriving the confirmed change against the revision the
* editor currently holds. There is no path from a click straight to a PATCH.
* the last parsed read produced, the draft lives in the machine, and Save
* captures one patch against that revision before any request is issued.
*
* It is a hook rather than a panel because the effective-configuration table,
* the inline review, and the inspector all read the same machine: the row that
* says `edited`, the panel that shows the frozen patch, and the register that
* says `review pending` are three views of one state.
* It is a hook rather than a panel because the table, inline editor, and
* inspector all read the same machine.
*/

import { useMutation } from '@tanstack/react-query';
Expand All @@ -34,15 +30,14 @@ import { authorityValue, withFieldValue, type SettingsBinding } from './settings

export interface SettingsEditorHandle {
readonly state: SettingsEditorState;
/** Replace one bound field in the draft. Drops any open review, as the machine does. */
/** Replace one bound field in the draft. */
readonly edit: (binding: SettingsBinding, value: unknown) => void;
/** Take the authority's value back for one bound field, leaving other edits alone. */
readonly revert: (binding: SettingsBinding) => void;
/** Freeze this scope's change against the held revision. */
readonly review: (scope: SettingsScope) => void;
readonly setConfirmed: (confirmed: boolean) => void;
readonly apply: () => void;
readonly dismiss: () => void;
/** Save this scope's current draft against its held revision. */
readonly save: (scope: SettingsScope) => void;
/** Retry the same immutable write with its original idempotency key. */
readonly retry: () => void;
/** Discard every draft and take the authority's current values. */
readonly reload: () => void;
}
Expand All @@ -51,12 +46,12 @@ export function useSettingsEditor({
payload,
routes,
writability,
onApplied,
refresh,
}: {
payload: SettingsPayloadV1;
routes: SettingsRoutes;
writability: ScopeWritability;
onApplied: () => void;
refresh: () => Promise<SettingsPayloadV1 | null>;
}): SettingsEditorHandle {
const authority = useMemo(() => buildSettingsEditor(payload), [payload]);
const [state, dispatch] = useReducer(
Expand All @@ -73,7 +68,7 @@ export function useSettingsEditor({
mutationFn: applySettingsMutation,
onSuccess: (result) => {
dispatch({ type: 'submit_settled', result });
if (result.outcome === 'success') onApplied();
if (result.outcome === 'success') void refresh();
},
onError: (error) => {
dispatch({
Expand All @@ -83,17 +78,16 @@ export function useSettingsEditor({
},
});

// The request is derived from the submitting state, so it cannot be built
// from a draft the machine has not checked. The ref keeps one entry into
// `submitting` to one PATCH; leaving the state releases it for a retry.
// One transition into `submitting` can issue one request. Leaving that state
// releases the guard for a retry with the same idempotency key.
const inFlight = useRef<string | null>(null);
useEffect(() => {
if (state.status !== 'submitting') {
inFlight.current = null;
return;
}
if (inFlight.current === state.review.reviewId) return;
inFlight.current = state.review.reviewId;
if (inFlight.current === state.submission.idempotencyKey) return;
inFlight.current = state.submission.idempotencyKey;
// Project and ordinary user writes use the current project gateway reading.
// The ProfileSessions worker preference is profile-global, so its request
// never inherits selected-project writability.
Expand Down Expand Up @@ -126,27 +120,36 @@ export function useSettingsEditor({
[state],
);

const review = useCallback((scope: SettingsScope) => {
const save = useCallback((scope: SettingsScope) => {
dispatch({
type: 'review_requested',
type: 'save_requested',
scope,
idempotencyKey: mintBrowserIdempotencyKey('dashboard-settings'),
});
}, []);

const setConfirmed = useCallback((confirmed: boolean) => {
dispatch({ type: 'confirmation_set', confirmed });
}, []);
const apply = useCallback(() => dispatch({ type: 'submit_started' }), []);
const dismiss = useCallback(() => dispatch({ type: 'review_dismissed' }), []);
const reload = useCallback(() => {
dispatch({ type: 'reloaded_from_authority' });
onApplied();
}, [onApplied]);
const retry = useCallback(() => dispatch({ type: 'retry_requested' }), []);
const reloadPending = useRef(false);
const reload = useCallback(async () => {
if (reloadPending.current) return;
reloadPending.current = true;
try {
const current = await refresh();
const reloadedAuthority = current === null ? null : buildSettingsEditor(current);
// A failed or pending read cannot settle an uncertain write. Carry the
// successful read explicitly: React Query may reuse a snapshot already
// observed while the draft was dirty, so its effect need not run again.
if (reloadedAuthority !== null) {
dispatch({ type: 'reloaded_from_authority', authority: reloadedAuthority });
}
} finally {
reloadPending.current = false;
}
}, [refresh]);

return useMemo(
() => ({ state, edit, revert, review, setConfirmed, apply, dismiss, reload }),
[state, edit, revert, review, setConfirmed, apply, dismiss, reload],
() => ({ state, edit, revert, save, retry, reload }),
[state, edit, revert, save, retry, reload],
);
}

Expand Down
Loading
Loading