textmachine/frontend/src/showcase/RunStart.tsx

189 lines
7.8 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { ApiError, keys, runOptionsQuery, startRun } from '../api';
import type { CeilingBounds, Id, Run } from '../api';
import { useText, type MessageKey, type Text } from '../i18n/text';
import { Button } from '../ui/Button';
import { Checkbox } from '../ui/Checkbox';
import { Modal } from '../ui/Modal';
import { Slider } from '../ui/Slider';
import { Blank } from './Blank';
import { Loaded } from './Loaded';
import { counted } from './format';
import { chapters as chapterForms } from './units';
import styles from './RunStart.module.css';
interface Props {
bookId: Id;
isOpen: boolean;
onClose: () => void;
}
/**
* Starting a translation: the ceiling of THIS run and the stop at the bank.
*
* The bounds are read right before the form is shown and not taken off the book card, because the
* maximum belongs to the ACCOUNT and moves while the book does not (contract, `run-options`). They
* arrive already trimmed and are not clamped again here: a second clamp on the client would be a
* second copy of a policy that lives on the platform.
*
* ⚠ The scale counts CHAPTERS and nothing else. There is no sum, no estimate and no money on this
* screen in any form — the chapters → money conversion is the platform's whole and only (§4.8).
*/
export function RunStart({ bookId, isOpen, onClose }: Props) {
const text = useText();
const client = useQueryClient();
// Read only while the window is open: bounds fetched behind a closed form are bounds that will be
// stale by the time it opens.
const options = useQuery({ ...runOptionsQuery(bookId), enabled: isOpen });
return (
<Modal title={text('run.title')} isOpen={isOpen} onClose={onClose}>
<Loaded query={options} waiting={text('run.waiting')}>
{(data) => (
<Form
bookId={bookId}
ceiling={data.ceiling}
onClose={onClose}
// ⚠ BOTH are re-read, because a 409 is not only a ceiling that moved: the platform
// answers it for a run already in flight and for a book not ready to be translated as
// well (`fail` in httpapi/v0.go). The bounds may be intact and the CARD stale — and the
// card is what decides whether the action is offered at all.
onConflict={() =>
Promise.all([
client.invalidateQueries({ queryKey: keys.runOptions(bookId) }),
client.invalidateQueries({ queryKey: keys.book(bookId) }),
])
}
/>
)}
</Loaded>
</Modal>
);
}
/**
* The form itself, mounted only once the bounds are known.
*
* ⚠ When the bounds MOVE — which is what a `409` means — the chosen value goes back to the
* platform's new preset, because a value picked against bounds that no longer exist is not a
* choice. Adjusted during the render rather than by remounting the form or by an effect: a remount
* would take the refusal off the screen at the very moment it has to be read, and an effect would
* draw the old scale for one frame first.
*/
function Form({
bookId,
ceiling,
onClose,
onConflict,
}: {
bookId: Id;
ceiling: CeilingBounds;
onClose: () => void;
onConflict: () => Promise<unknown>;
}) {
const text = useText();
const client = useQueryClient();
const [chapters, setChapters] = useState(ceiling.default_chapters);
// The bounds the value above was chosen against. The query layer keeps the identity of data that
// did not change (structural sharing), so this fires on a real move of the bounds and not on
// every refetch.
const [chosenAgainst, setChosenAgainst] = useState(ceiling);
if (chosenAgainst !== ceiling) {
setChosenAgainst(ceiling);
setChapters(ceiling.default_chapters);
}
// ⚠ OFF by default, and that is a choice about TODAY rather than about the product. With the stop
// on, a run ends at `awaiting_bank` — and nothing in this build can take it further: the signing
// screen is S5, the bank has no read channel at all (BACKLOG Ф-43), and `resume` answers 409
// while the set of decisions is incomplete (contract §resumeRun). Preselecting it would be
// preselecting a dead end. The checkbox itself stays, because the choice is the user's and belongs
// to THIS run (owner, 02.08); the default flips back the day S5 gives the stop somewhere to go.
const [verifyBank, setVerifyBank] = useState(false);
const run = useMutation<Run, Error>({
mutationFn: () => startRun(bookId, { ceiling_chapters: chapters, verify_bank: verifyBank }),
onSuccess: () => {
// The run lives on the book card — that is where the shell reads the stream's id from.
void client.invalidateQueries({ queryKey: keys.book(bookId) });
onClose();
},
onError: (error) => {
// A 409 says the state moved between the read and this call. What moved is the platform's to
// name (its phrase reaches the screen untouched); ours is to read the state again and let the
// person look — never a quiet retry with a request the platform has just refused.
if (error instanceof ApiError && error.status === 409) void onConflict();
},
});
// `max_chapters: 0` is not a scale with nothing on it: no run can start at all, and the honest
// shape of that is the exhausted state instead of a control that cannot be used.
if (ceiling.max_chapters === 0) {
return (
<Blank title={text('run.exhaustedTitle')} description={text('run.exhaustedDescription')} />
);
}
return (
<div className={styles.form}>
<Slider
label={text('about.ceiling')}
value={chapters}
min={ceiling.min_chapters}
max={ceiling.max_chapters}
onChange={setChapters}
valueLabel={counted(chapters, chapterForms)}
hint={text('run.ceilingHint')}
/>
<div className={styles.option}>
<Checkbox label={text('run.verifyBank')} isSelected={verifyBank} onChange={setVerifyBank} />
<p className={styles.hint}>{text('run.verifyBankHint')}</p>
</div>
{/* A permanent live region, as in the intake form: the refusal appears away from the focus,
and a reader that is not told is a reader who thinks the press did nothing (WCAG 4.1.3). */}
<div role="status" className={styles.live}>
{run.isError && <Refused error={run.error} />}
</div>
<div className={styles.actions}>
<Button look="action" onPress={onClose}>
{text('action.cancel')}
</Button>
<Button look="primary" isDisabled={run.isPending} onPress={() => run.mutate()}>
{text('run.action')}
</Button>
</div>
</div>
);
}
/**
* Why the run did not start. As in the intake form, the phrase is the platform's where it sent one
* and the ADVICE is ours: a moved ceiling and a deployment that cannot run at all end in different
* next actions, and neither of them is "press it again".
*/
function Refused({ error }: { error: Error }) {
const text = useText();
const [phrase, advice] = refusal(error, text);
return (
<div className={styles.refused}>
<p className={styles.phrase}>{phrase}</p>
<p className={styles.hint}>{text(advice)}</p>
</div>
);
}
function refusal(error: Error, text: Text): [string, MessageKey] {
if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice'];
if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice'];
const own: Partial<Record<number, [MessageKey, MessageKey]>> = {
409: ['run.conflict', 'run.conflictAdvice'],
503: ['run.unavailable', 'run.unavailableAdvice'],
};
const [fallback, advice] = own[error.status] ?? ['run.failedTitle', 'upload.refusedAdvice'];
return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice];
}