Merge branch 'feature-async-ui-state-contract' into develop
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
export const ASYNC_BASE_STATES = Object.freeze([
|
||||||
|
"initial-loading",
|
||||||
|
"success",
|
||||||
|
"empty",
|
||||||
|
"terminal-error",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ASYNC_OVERLAYS = Object.freeze([
|
||||||
|
"refreshing",
|
||||||
|
"stale-degraded",
|
||||||
|
"mutation-pending",
|
||||||
|
"mutation-conflict",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* data?: unknown,
|
||||||
|
* isInitialLoading?: boolean,
|
||||||
|
* failure?: import("../../contracts/errors.js").ApiFailure,
|
||||||
|
* isFetching?: boolean,
|
||||||
|
* isStale?: boolean,
|
||||||
|
* isDegraded?: boolean,
|
||||||
|
* isMutationPending?: boolean,
|
||||||
|
* hasMutationConflict?: boolean
|
||||||
|
* }} AsyncSignals
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {AsyncSignals} signals */
|
||||||
|
export function deriveAsyncState(signals) {
|
||||||
|
const hasData = signals.data !== undefined && signals.data !== null;
|
||||||
|
const empty =
|
||||||
|
hasData &&
|
||||||
|
((Array.isArray(signals.data) && signals.data.length === 0) ||
|
||||||
|
signals.data === "");
|
||||||
|
|
||||||
|
let base;
|
||||||
|
if (signals.isInitialLoading && !hasData) {
|
||||||
|
base = "initial-loading";
|
||||||
|
} else if (signals.failure && !hasData) {
|
||||||
|
base = "terminal-error";
|
||||||
|
} else if (empty) {
|
||||||
|
base = "empty";
|
||||||
|
} else if (hasData) {
|
||||||
|
base = "success";
|
||||||
|
} else {
|
||||||
|
base = "initial-loading";
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = Object.freeze({
|
||||||
|
refreshing: Boolean(signals.isFetching && hasData),
|
||||||
|
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
|
||||||
|
mutationPending: Boolean(signals.isMutationPending && hasData),
|
||||||
|
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
base,
|
||||||
|
data: base === "success" || base === "empty" ? signals.data : undefined,
|
||||||
|
failure: base === "terminal-error" ? signals.failure : undefined,
|
||||||
|
overlay,
|
||||||
|
indicator: selectOverlayIndicator(overlay),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.freeze(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* refreshing: boolean,
|
||||||
|
* staleDegraded: boolean,
|
||||||
|
* mutationPending: boolean,
|
||||||
|
* mutationConflict: boolean
|
||||||
|
* }} overlay
|
||||||
|
*/
|
||||||
|
export function selectOverlayIndicator(overlay) {
|
||||||
|
if (overlay.mutationConflict) return "mutation-conflict";
|
||||||
|
if (overlay.mutationPending) return "mutation-pending";
|
||||||
|
if (overlay.staleDegraded) return "stale-degraded";
|
||||||
|
if (overlay.refreshing) return "refreshing";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,13 +9,18 @@ const DROP_SENSITIVE = Object.freeze([
|
|||||||
"storageValue",
|
"storageValue",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {"retry" | "reauth" | "navigate" | "reload-once" |
|
||||||
|
* "contact-support" | "none"} ErrorAction
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: string,
|
* kind: string,
|
||||||
* defaultRetryable: boolean,
|
* defaultRetryable: boolean,
|
||||||
* severity: string,
|
* severity: string,
|
||||||
* userMessageKey: string,
|
* userMessageKey: string,
|
||||||
* action: string,
|
* action: ErrorAction,
|
||||||
* telemetryEvent: string,
|
* telemetryEvent: string,
|
||||||
* redaction: readonly string[]
|
* redaction: readonly string[]
|
||||||
* }} ErrorDefinition
|
* }} ErrorDefinition
|
||||||
@@ -25,7 +30,7 @@ const DROP_SENSITIVE = Object.freeze([
|
|||||||
* @param {string} kind
|
* @param {string} kind
|
||||||
* @param {boolean} defaultRetryable
|
* @param {boolean} defaultRetryable
|
||||||
* @param {string} severity
|
* @param {string} severity
|
||||||
* @param {string} action
|
* @param {ErrorAction} action
|
||||||
* @param {string} [telemetryEvent]
|
* @param {string} [telemetryEvent]
|
||||||
* @returns {Readonly<ErrorDefinition>}
|
* @returns {Readonly<ErrorDefinition>}
|
||||||
*/
|
*/
|
||||||
@@ -156,7 +161,7 @@ export const ERROR_REGISTRY = Object.freeze({
|
|||||||
* traceId?: string,
|
* traceId?: string,
|
||||||
* retryAfterMs?: number,
|
* retryAfterMs?: number,
|
||||||
* userMessageKey: string,
|
* userMessageKey: string,
|
||||||
* action: string,
|
* action: ErrorAction,
|
||||||
* causeClass?: string
|
* causeClass?: string
|
||||||
* }} ApiFailure
|
* }} ApiFailure
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/** @param {{ label?: string }} props */
|
||||||
|
export function LoadingSurface({ label = "불러오는 중" }) {
|
||||||
|
return (
|
||||||
|
<section aria-busy="true" aria-label={label}>
|
||||||
|
<div className="ui-skeleton" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{ title?: string, action?: React.ReactNode }} props */
|
||||||
|
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p>{title}</p>
|
||||||
|
{action}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* userMessageKey: string,
|
||||||
|
* action: "retry" | "reauth" | "navigate" | "reload-once" |
|
||||||
|
* "contact-support" | "none",
|
||||||
|
* onAction?: () => void
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
|
||||||
|
return (
|
||||||
|
<section role="alert" aria-labelledby="terminal-error-message">
|
||||||
|
<p id="terminal-error-message">{userMessageKey}</p>
|
||||||
|
{action !== "none" && (
|
||||||
|
<button type="button" onClick={onAction}>
|
||||||
|
{action}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
|
||||||
|
* children?: React.ReactNode,
|
||||||
|
* onAction?: () => void
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function AsyncSurface({ state, children, onAction }) {
|
||||||
|
if (state.base === "initial-loading") return <LoadingSurface />;
|
||||||
|
if (state.base === "empty") return <EmptySurface />;
|
||||||
|
if (state.base === "terminal-error" && state.failure) {
|
||||||
|
return (
|
||||||
|
<TerminalErrorSurface
|
||||||
|
userMessageKey={state.failure.userMessageKey}
|
||||||
|
action={state.failure.action}
|
||||||
|
onAction={onAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
|
||||||
|
{state.indicator && (
|
||||||
|
<p role="status" aria-live="polite">
|
||||||
|
{state.indicator}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
|
||||||
|
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||||
|
import { createFailure } from "../../src/contracts/errors.js";
|
||||||
|
|
||||||
|
describe("async UI state matrix", () => {
|
||||||
|
it.each([
|
||||||
|
[{ isInitialLoading: true }, "initial-loading"],
|
||||||
|
[{ data: [{ id: "1" }] }, "success"],
|
||||||
|
[{ data: [] }, "empty"],
|
||||||
|
[
|
||||||
|
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
|
||||||
|
"terminal-error",
|
||||||
|
],
|
||||||
|
])("derives base state %#", (signals, expected) => {
|
||||||
|
expect(deriveAsyncState(signals).base).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ data: ["value"], isFetching: true }, "refreshing"],
|
||||||
|
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
||||||
|
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
||||||
|
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
||||||
|
])("derives overlay state %#", (signals, indicator) => {
|
||||||
|
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses deterministic overlay priority for crossed states", () => {
|
||||||
|
const state = deriveAsyncState({
|
||||||
|
data: ["value"],
|
||||||
|
isFetching: true,
|
||||||
|
isMutationPending: true,
|
||||||
|
hasMutationConflict: true,
|
||||||
|
});
|
||||||
|
expect(state.indicator).toBe("mutation-conflict");
|
||||||
|
expect(state.overlay).toMatchObject({
|
||||||
|
refreshing: true,
|
||||||
|
mutationPending: true,
|
||||||
|
mutationConflict: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content visible while a non-blocking refresh runs", () => {
|
||||||
|
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
||||||
|
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||||
|
|
||||||
|
expect(screen.getByText("existing content")).toBeVisible();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only safe error vocabulary", () => {
|
||||||
|
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
|
||||||
|
code: "SERVER_FAILURE",
|
||||||
|
});
|
||||||
|
const state = deriveAsyncState({ failure });
|
||||||
|
render(<AsyncSurface state={state} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
|
||||||
|
expect(screen.getByRole("button")).toHaveTextContent("retry");
|
||||||
|
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retain a terminal error after usable data is restored", () => {
|
||||||
|
const failed = deriveAsyncState({
|
||||||
|
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||||
|
});
|
||||||
|
const recovered = deriveAsyncState({ data: ["value"] });
|
||||||
|
expect(failed.base).toBe("terminal-error");
|
||||||
|
expect(recovered.base).toBe("success");
|
||||||
|
expect(recovered.failure).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user