mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: mii edit UX, a11y fixes, and frontend unit tests
- API: return success message on mii edit - FE: flash banner + helpers, submit response helper, Vitest - A11y: labels, alt text, banner close; css checkerboard class - DX: root tsconfig refs, strict/tsconfig, next dev --webpack Made-with: Cursor
This commit is contained in:
parent
7e10cef0b8
commit
13b2bef574
21 changed files with 1417 additions and 53 deletions
177
frontend/src/lib/mii-edit-notifications.test.ts
Normal file
177
frontend/src/lib/mii-edit-notifications.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
deriveEditSuccessFlashFromLocationState,
|
||||
errorMessageAfterJsonFailure,
|
||||
interpretMiiEditResponse,
|
||||
} from "./mii-edit-notifications";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("interpretMiiEditResponse", () => {
|
||||
it("returns success navigation with server message for the green banner", () => {
|
||||
const body = { success: true, message: "Mii updated successfully." };
|
||||
const response = jsonResponse(200, body);
|
||||
const out = interpretMiiEditResponse(response, body, 42);
|
||||
expect(out).toEqual({
|
||||
kind: "success",
|
||||
navigate: {
|
||||
path: "/mii/42",
|
||||
state: { miiEdited: true, miiEditedMessage: "Mii updated successfully." },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns success without miiEditedMessage when message is missing (default copy on Mii page)", () => {
|
||||
const body = { success: true };
|
||||
const response = jsonResponse(200, body);
|
||||
const out = interpretMiiEditResponse(response, body, 7);
|
||||
expect(out).toEqual({
|
||||
kind: "success",
|
||||
navigate: {
|
||||
path: "/mii/7",
|
||||
state: { miiEdited: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns API error message on non-OK response", () => {
|
||||
const body = { error: "Nothing was changed" };
|
||||
const response = jsonResponse(400, body);
|
||||
const out = interpretMiiEditResponse(response, body, 1);
|
||||
expect(out).toEqual({ kind: "failure", error: "Nothing was changed" });
|
||||
});
|
||||
|
||||
it("uses status fallback when OK but success is false", () => {
|
||||
const response = jsonResponse(200, { success: false, error: "bad" });
|
||||
const out = interpretMiiEditResponse(response, { success: false, error: "bad" }, 2);
|
||||
expect(out).toEqual({ kind: "failure", error: "bad" });
|
||||
});
|
||||
|
||||
it("uses status fallback when OK, success false, and error is not a string", () => {
|
||||
const response = jsonResponse(200, { success: false });
|
||||
const out = interpretMiiEditResponse(response, { success: false }, 3);
|
||||
expect(out).toEqual({ kind: "failure", error: "Could not save changes. Please try again." });
|
||||
});
|
||||
|
||||
it("formats error when non-OK and error field is missing", () => {
|
||||
const response = jsonResponse(500, {});
|
||||
const out = interpretMiiEditResponse(response, {}, 9);
|
||||
expect(out).toEqual({ kind: "failure", error: "Could not save changes (500). Please try again." });
|
||||
});
|
||||
|
||||
it("429 rate limit preserves string message from API", () => {
|
||||
const body = { error: "Rate limit exceeded. Please try again later." };
|
||||
const response = jsonResponse(429, body);
|
||||
expect(interpretMiiEditResponse(response, body, 1)).toEqual({
|
||||
kind: "failure",
|
||||
error: "Rate limit exceeded. Please try again later.",
|
||||
});
|
||||
});
|
||||
|
||||
it("non-OK converts non-string error via String()", () => {
|
||||
const payload = { error: { nest: true } };
|
||||
const response = jsonResponse(400, payload);
|
||||
expect(interpretMiiEditResponse(response, payload as unknown, 1)).toEqual({
|
||||
kind: "failure",
|
||||
error: "[object Object]",
|
||||
});
|
||||
});
|
||||
|
||||
it("OK with success missing is treated like success false", () => {
|
||||
const response = jsonResponse(200, {});
|
||||
expect(interpretMiiEditResponse(response, {}, 55)).toEqual({
|
||||
kind: "failure",
|
||||
error: "Could not save changes. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it('empty-string message keys are kept (shows empty banner vs default — contract test)', () => {
|
||||
const body = { success: true, message: "" };
|
||||
const response = jsonResponse(200, body);
|
||||
expect(interpretMiiEditResponse(response, body, 2)).toEqual({
|
||||
kind: "success",
|
||||
navigate: {
|
||||
path: "/mii/2",
|
||||
state: { miiEdited: true, miiEditedMessage: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("numeric message field is ignored (not a banner string)", () => {
|
||||
const body = { success: true, message: 12345 };
|
||||
const response = jsonResponse(200, body as unknown as object);
|
||||
expect(interpretMiiEditResponse(response, body, 3)).toEqual({
|
||||
kind: "success",
|
||||
navigate: { path: "/mii/3", state: { miiEdited: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses large mii id in path without mangling", () => {
|
||||
const body = { success: true };
|
||||
const response = jsonResponse(200, body);
|
||||
expect(interpretMiiEditResponse(response, body, 9_876_543)).toEqual({
|
||||
kind: "success",
|
||||
navigate: { path: "/mii/9876543", state: { miiEdited: true } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorMessageAfterJsonFailure", () => {
|
||||
it("invalid JSON when response was OK", () => {
|
||||
const r = new Response("not json", { status: 200 });
|
||||
expect(errorMessageAfterJsonFailure(r)).toBe("Invalid response from server.");
|
||||
});
|
||||
|
||||
it("invalid JSON when response was error", () => {
|
||||
const r = new Response("<html>", { status: 502 });
|
||||
expect(errorMessageAfterJsonFailure(r)).toBe("Something went wrong (502). Please try again.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveEditSuccessFlashFromLocationState", () => {
|
||||
it("shows banner with custom message after navigate from edit", () => {
|
||||
expect(
|
||||
deriveEditSuccessFlashFromLocationState({
|
||||
miiEdited: true,
|
||||
miiEditedMessage: "Mii updated successfully.",
|
||||
}),
|
||||
).toEqual({ show: true, message: "Mii updated successfully." });
|
||||
});
|
||||
|
||||
it("shows banner with undefined message (UI uses default string)", () => {
|
||||
expect(deriveEditSuccessFlashFromLocationState({ miiEdited: true })).toEqual({
|
||||
show: true,
|
||||
message: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides banner when navigation has no edit flag", () => {
|
||||
expect(deriveEditSuccessFlashFromLocationState(null)).toEqual({ show: false });
|
||||
expect(deriveEditSuccessFlashFromLocationState(undefined)).toEqual({ show: false });
|
||||
expect(deriveEditSuccessFlashFromLocationState({})).toEqual({ show: false });
|
||||
});
|
||||
|
||||
it("hides banner when miiEdited is explicitly false", () => {
|
||||
expect(
|
||||
deriveEditSuccessFlashFromLocationState({
|
||||
miiEdited: false,
|
||||
miiEditedMessage: "ignored",
|
||||
}),
|
||||
).toEqual({ show: false });
|
||||
});
|
||||
|
||||
it("ignores non-string miiEditedMessage in location state", () => {
|
||||
expect(
|
||||
deriveEditSuccessFlashFromLocationState({
|
||||
miiEdited: true,
|
||||
miiEditedMessage: 123 as unknown as string,
|
||||
}),
|
||||
).toEqual({ show: true, message: undefined });
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/mii-edit-notifications.ts
Normal file
74
frontend/src/lib/mii-edit-notifications.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Pure helpers for POST /api/mii/:id/edit responses and Router state for the edit-success banner.
|
||||
* Kept dependency-free so they are easy to unit test.
|
||||
*/
|
||||
|
||||
export type MiiEditApiBody = {
|
||||
success?: boolean;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
export type NavigateAfterEditState = {
|
||||
miiEdited: boolean;
|
||||
miiEditedMessage?: string;
|
||||
};
|
||||
|
||||
/** What to render on the Mii page from Router location.state (edit success navigation). */
|
||||
export function deriveEditSuccessFlashFromLocationState(locationState: unknown): { show: boolean; message?: string } {
|
||||
const s = locationState as { miiEdited?: boolean; miiEditedMessage?: string } | null;
|
||||
return s?.miiEdited ? { show: true, message: typeof s.miiEditedMessage === "string" ? s.miiEditedMessage : undefined } : { show: false };
|
||||
}
|
||||
|
||||
/** Error string when fetch().json() throws but we already received a Response. */
|
||||
export function errorMessageAfterJsonFailure(response: Response): string {
|
||||
return response.ok ? "Invalid response from server." : `Something went wrong (${response.status}). Please try again.`;
|
||||
}
|
||||
|
||||
/** Result after a successful JSON body parse — either show error on edit page or navigate with flash state. */
|
||||
export type InterpretMiiEditOutcome =
|
||||
| { kind: "failure"; error: string }
|
||||
| {
|
||||
kind: "success";
|
||||
navigate: {
|
||||
path: string;
|
||||
state: NavigateAfterEditState;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps HTTP status + parsed JSON from the edit endpoint to either an inline error string
|
||||
* or the navigation payload used after a successful save (including Router state for the green banner).
|
||||
*/
|
||||
export function interpretMiiEditResponse(response: Response, data: unknown, miiId: number): InterpretMiiEditOutcome {
|
||||
const body = data as MiiEditApiBody;
|
||||
|
||||
if (!response.ok) {
|
||||
const msg =
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: body.error !== undefined
|
||||
? String(body.error)
|
||||
: `Could not save changes (${response.status}). Please try again.`;
|
||||
return { kind: "failure", error: msg };
|
||||
}
|
||||
|
||||
if (!body.success) {
|
||||
const err = typeof body.error === "string" ? body.error : "Could not save changes. Please try again.";
|
||||
return { kind: "failure", error: err };
|
||||
}
|
||||
|
||||
const message = typeof body.message === "string" ? body.message : undefined;
|
||||
const state: NavigateAfterEditState = {
|
||||
miiEdited: true,
|
||||
...(message !== undefined ? { miiEditedMessage: message } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
navigate: {
|
||||
path: `/mii/${miiId}`,
|
||||
state,
|
||||
},
|
||||
};
|
||||
}
|
||||
52
frontend/src/lib/submit-response.test.ts
Normal file
52
frontend/src/lib/submit-response.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { interpretSubmitResponse } from "./submit-response";
|
||||
|
||||
describe("interpretSubmitResponse", () => {
|
||||
it("success returns numeric mii id", () => {
|
||||
const response = jsonResponse(200, { id: 99 });
|
||||
expect(interpretSubmitResponse(response, { id: 99 })).toEqual({ kind: "success", miiId: 99 });
|
||||
});
|
||||
|
||||
it("failure forwards string error when not ok", () => {
|
||||
const response = jsonResponse(503, { error: "Submissions are temporarily disabled" });
|
||||
expect(interpretSubmitResponse(response, { error: "Submissions are temporarily disabled" })).toEqual({
|
||||
kind: "failure",
|
||||
error: "Submissions are temporarily disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("failure mirrors String(undefined) when not ok and error missing", () => {
|
||||
const response = jsonResponse(400, {});
|
||||
expect(interpretSubmitResponse(response, {}).error).toBe("undefined");
|
||||
});
|
||||
|
||||
it("invalid id shape on OK response is rejected", () => {
|
||||
const response = jsonResponse(200, { id: "not-a-number" });
|
||||
expect(interpretSubmitResponse(response, { id: "not-a-number" })).toEqual({
|
||||
kind: "failure",
|
||||
error: "Invalid response from server.",
|
||||
});
|
||||
});
|
||||
|
||||
it("non-OK with object error matches String semantics", () => {
|
||||
const errObj = { field: "x" };
|
||||
const response = jsonResponse(422, { error: errObj });
|
||||
expect(interpretSubmitResponse(response, { error: errObj })).toEqual({
|
||||
kind: "failure",
|
||||
error: String(errObj),
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates float id when server returned a whole number coerced oddly", () => {
|
||||
const response = jsonResponse(200, { id: 12.0 });
|
||||
expect(interpretSubmitResponse(response, { id: 12 })).toEqual({ kind: "success", miiId: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
30
frontend/src/lib/submit-response.ts
Normal file
30
frontend/src/lib/submit-response.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Pure helper for POST /api/submit responses (new Mii ID or error).
|
||||
* Unit-testable without running the Next server.
|
||||
*/
|
||||
|
||||
export type SubmitApiBody = {
|
||||
id?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type InterpretSubmitOutcome =
|
||||
| { kind: "failure"; error: string }
|
||||
| { kind: "success"; miiId: number };
|
||||
|
||||
/**
|
||||
* Mirrors the submit page: `error` destructured from JSON — `String(error)` includes `"undefined"` if missing.
|
||||
*/
|
||||
export function interpretSubmitResponse(response: Response, data: unknown): InterpretSubmitOutcome {
|
||||
const body = data as SubmitApiBody;
|
||||
|
||||
if (!response.ok) {
|
||||
return { kind: "failure", error: String(body.error) };
|
||||
}
|
||||
|
||||
if (typeof body.id !== "number" || !Number.isFinite(body.id)) {
|
||||
return { kind: "failure", error: "Invalid response from server." };
|
||||
}
|
||||
|
||||
return { kind: "success", miiId: Math.trunc(body.id) };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue