feat(frontend): Przywróć teraz for pending changes in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9fbff4eea
commit
09042c6338
4 files changed
+131
-9
No files matched your search
@@ -171,4 +171,73 @@ describe("Settings", () => {
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
expect(screen.getByText("Przywrócić 8 °C teraz?")).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
const loads = fetch.mock.calls.length;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
expect(posts(fetch)[0][0]).toBe("api/setpoints/cwu_economy/restore");
|
||||
expect(await screen.findByText("Przywrócono poprzednią wartość.")).toBeInTheDocument();
|
||||
expect(fetch.mock.calls.length).toBeGreaterThan(loads + 1);
|
||||
});
|
||||
|
||||
it("sends nothing when cancelled", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(await restoreButton()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has a button large enough to tap", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect((await restoreButton()).className).toContain("min-h-12");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CONFLICT", 409, "zostawiliśmy ją bez zmian"],
|
||||
["TOO_SOON", 429, "Za wcześnie"],
|
||||
["FAILED", 502, "Zmiana nadal czeka"],
|
||||
["NOT_PENDING", 404, "już się zakończyła"],
|
||||
])("explains %s in Polish", async (outcome, status, text) => {
|
||||
serve({ "api/setpoints": example("setpoints") }, restoreAnswer(outcome, status));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("never claims nothing changed when the answer is lost", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
|
||||
import type { Change, CommandResult, LineStatus, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
@@ -27,6 +27,13 @@ const RESULT_TEXT: Record<string, string> = {
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
const RESTORE_TEXT: Record<RestoreOutcome, string> = {
|
||||
RESTORED: "Przywrócono poprzednią wartość.",
|
||||
NOT_PENDING: "Ta zmiana już się zakończyła. Nic nie zostało zmienione.",
|
||||
CONFLICT: "Wartość została w międzyczasie zmieniona (np. na panelu pompy), więc zostawiliśmy ją bez zmian.",
|
||||
TOO_SOON: "Za wcześnie po poprzednim zapisie. Spróbuj za minutę.",
|
||||
FAILED: "Nie udało się przywrócić. Zmiana nadal czeka, spróbujemy ponownie sami. Sprawdź wartości poniżej.",
|
||||
};
|
||||
const LINE_TEXT: Record<LineStatus, string> = {
|
||||
APPLIED: "zapisano",
|
||||
REJECTED: "pompa nie przyjęła zmiany",
|
||||
@@ -81,6 +88,8 @@ function ResultBox({ result }: { result: Result }) {
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
} else if (ok && result.text) {
|
||||
body = result.text;
|
||||
} else if (ok) {
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
|
||||
} else {
|
||||
@@ -132,6 +141,8 @@ export function Settings() {
|
||||
const [presetId, setPresetId] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The setpoint whose pending change is about to be restored, waiting for "Tak, przywróć".
|
||||
const [restoring, setRestoring] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Result>(null);
|
||||
|
||||
// A refresh can lose the reading while a change is half-made; drop what was built on it.
|
||||
@@ -145,7 +156,7 @@ export function Settings() {
|
||||
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
|
||||
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setRestoring(null); setResult(null); };
|
||||
|
||||
async function send(url: string, payload: unknown, changes?: Change[]) {
|
||||
setBusy(true);
|
||||
@@ -166,6 +177,23 @@ export function Settings() {
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
setBusy(true);
|
||||
let answer: Result;
|
||||
try {
|
||||
const { outcome } = await postJson<RestoreResult>(`${URL}/${id}/restore`);
|
||||
answer = { status: outcome === "RESTORED" ? "APPLIED" : "FAILED", text: RESTORE_TEXT[outcome] };
|
||||
} catch (error) {
|
||||
const status = error instanceof WriteError ? error.status : 0;
|
||||
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
|
||||
}
|
||||
setResult(answer);
|
||||
setBusy(false);
|
||||
setRestoring(null);
|
||||
window.scrollTo(0, 0);
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
function step(s: Setpoint, direction: number) {
|
||||
if (unread(s)) return;
|
||||
setResult(null);
|
||||
@@ -211,9 +239,25 @@ export function Settings() {
|
||||
{data.restores.length > 0 && (
|
||||
<Card title="Zmiany czasowe">
|
||||
{data.restores.map((r) => (
|
||||
<p key={r.setpoint} className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
<div key={r.setpoint} className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<p className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
{restoring === r.setpoint ? (
|
||||
<>
|
||||
<p className="my-2 text-lg font-semibold">Przywrócić {degrees(r.originalValue)} teraz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setRestoring(null)} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => restore(r.setpoint)} disabled={busy}>Tak, przywróć</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 flex">
|
||||
<Button aria-label={`Przywróć teraz: ${name(r.setpoint)}`} disabled={busy}
|
||||
onClick={() => { setResult(null); setRestoring(r.setpoint); }}>Przywróć teraz</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
+6
-4
@@ -16,10 +16,12 @@ export class WriteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
|
||||
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
|
||||
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// and with status 0 when the network fails or the answer does not come in time.
|
||||
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
|
||||
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
|
||||
url: string, body?: unknown,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new WriteError(response.status);
|
||||
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
|
||||
return answer;
|
||||
}
|
||||
@@ -115,6 +115,13 @@ export interface Setpoints {
|
||||
|
||||
export type LineStatus = "APPLIED" | "REJECTED" | "FAILED" | "UNKNOWN" | "NOT_ATTEMPTED";
|
||||
|
||||
export type RestoreOutcome = "RESTORED" | "NOT_PENDING" | "CONFLICT" | "TOO_SOON" | "FAILED";
|
||||
|
||||
export interface RestoreResult {
|
||||
outcome: RestoreOutcome;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
|
||||
message?: string;
|
||||
|
||||
Reference in new issue
Block a user