189 lines
7.8 KiB
TypeScript
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];
|
|
}
|