diff --git a/apps/web/src/shared/components/ui/DataState.test.tsx b/apps/web/src/shared/components/ui/DataState.test.tsx new file mode 100644 index 000000000..7c0406074 --- /dev/null +++ b/apps/web/src/shared/components/ui/DataState.test.tsx @@ -0,0 +1,166 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +import { DataState } from "./DataState"; + +afterEach(cleanup); + +/** + * Contract tests for the DataState wrapper. Locks the precedence + * (error → loading → empty → success), the `refetch` plumbing, and + * the `stale` slot behaviour for background refetches. + */ +describe("DataState", () => { + it("renders the skeleton while the query is loading", () => { + render( + …} + > + {(data: number[]) => {data.length}} + , + ); + expect(screen.getByTestId("skeleton")).toBeTruthy(); + expect(screen.queryByTestId("body")).toBeNull(); + }); + + it("renders the empty slot when data is an empty array", () => { + render( + Порожньо} + > + {(data) => {data.length}} + , + ); + expect(screen.getByTestId("empty")).toBeTruthy(); + expect(screen.queryByTestId("body")).toBeNull(); + }); + + it("treats undefined data as empty when an empty slot is present", () => { + // Use a custom isEmpty so `data === null` counts as empty for an + // envelope-shaped response; default would also do so for plain + // `undefined`, but we exercise the custom path here. + render( + d.items.length === 0} + empty={
Нема
} + > + {(d) => {d.items.length}} +
, + ); + expect(screen.getByTestId("empty")).toBeTruthy(); + }); + + it("renders the error slot and forwards refetch via the retry callback", () => { + const refetch = vi.fn(); + const errorRenderer = vi.fn((err: Error, retry: () => void) => ( + + )); + + render( + + {() => } + , + ); + + const retryBtn = screen.getByTestId("retry"); + expect(retryBtn.textContent).toBe("boom"); + fireEvent.click(retryBtn); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("error wins even when stale data is present in the cache", () => { + render( + + {(data: number[]) => {data.length}} + , + ); + // Default fallback shows "Помилка" + the message. + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.queryByTestId("body")).toBeNull(); + }); + + it("renders body + stale slot when data is fresh and a refetch is in flight", () => { + render( + + isStale ? refreshing : null + } + > + {(data: number[]) => {data.length}} + , + ); + expect(screen.getByTestId("stale")).toBeTruthy(); + expect(screen.getByTestId("body").textContent).toBe("1"); + }); + + it("renders nothing when query is indeterminate (no data, no error, not loading)", () => { + const { container } = render( + + {() => } + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("default error fallback exposes a retry button that calls refetch", () => { + const refetch = vi.fn(); + render( + + {() => } + , + ); + fireEvent.click(screen.getByRole("button", { name: /спробувати/i })); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("falls through to children when no empty slot is provided even for an empty array", () => { + render( + + {(data) => len={data.length}} + , + ); + // No `empty` prop ⇒ DataState should NOT swallow the call. Body + // owns the decision so callers can render their own zero-state. + expect(screen.getByTestId("body").textContent).toBe("len=0"); + }); + + it("falls back to React Query v5 `isPending` when `isLoading` is absent", () => { + render( + …} + > + {() => } + , + ); + expect(screen.getByTestId("skeleton")).toBeTruthy(); + }); +}); diff --git a/apps/web/src/shared/components/ui/DataState.tsx b/apps/web/src/shared/components/ui/DataState.tsx new file mode 100644 index 000000000..b11633971 --- /dev/null +++ b/apps/web/src/shared/components/ui/DataState.tsx @@ -0,0 +1,238 @@ +import type { ReactNode } from "react"; +import { cn } from "@shared/lib/ui/cn"; +import { Button } from "./Button"; +import { SkeletonCard } from "./SkeletonCard"; + +/** + * Sergeant Design System — DataState + * + * Single-spot wrapper for the four canonical states a React Query (or + * any query-like) result can be in: **loading**, **empty**, **error**, + * **stale** (fresh data is visible but a background refetch is in + * flight). Use it to enforce a consistent skeleton/empty/error policy + * across modules instead of each page re-implementing its own + * `if (isLoading) ... if (error) ... if (!data?.length) ...` ladder. + * + * **Why a wrapper instead of one-off conditionals?** + * + * The diagnostic in `docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md` + * §3.2 calls out that we have neither a system-wide skeleton policy nor + * a uniform empty/error contract. Each page owns its own loading text, + * empty-state copy, and retry button — drift is constant and AI agents + * keep re-inventing the same affordances slightly differently. + * + * `` collapses the contract to: + * + * } // shape-aware loader + * empty={} + * error={(err, retry) => } + * stale={(_data, isStale) => isStale && } + * > + * {(data) => } + * + * + * The wrapper also accepts the lower-level shape (`isLoading` / + * `isError` / `data` / `error` / `refetch`) for hooks that don't expose + * a full RQ object — see `useMonoTransactions` for the canonical + * re-shape. + * + * **What it does NOT do.** This is purely presentational: it does not + * fetch, retry on its own, or talk to React Query directly. The host + * still owns the hook + key. That keeps the wrapper trivially testable + * and avoids a second `useQuery` call hiding behind the JSX. + */ + +/** + * Minimal contract every consumer must satisfy. We intentionally avoid + * a hard `UseQueryResult` import because some callers only have + * `{ data, isLoading, error }` (e.g. `useMonoTransactions`) and would + * otherwise have to fake the rest of the React Query shape. + */ +export interface DataStateQueryLike { + data: TData | undefined; + isLoading?: boolean; + isPending?: boolean; + isFetching?: boolean; + isError?: boolean; + error?: TError | null; + refetch?: () => unknown; +} + +export interface DataStateProps { + /** + * React Query-shaped result. `data` may be `undefined` while + * loading; `error` is read only if `isError` is `true` OR if `error` + * is non-null (callers from non-RQ hooks set `error` directly). + */ + query: DataStateQueryLike; + + /** + * Loading slot. Default is a generic ``. Pass a + * shape-aware skeleton (e.g. `` repeated) + * so the transition skeleton → real content reflows minimally. + */ + skeleton?: ReactNode; + + /** + * Empty slot — rendered when the query succeeded but returned a + * "nothing to show" payload. By default we treat `undefined`, + * `null`, `[]`, and `''` as empty. Override via `isEmpty(data)` if + * your domain has a different notion (e.g. a `{ items: [] }` envelope). + */ + empty?: ReactNode; + + /** + * Custom emptiness check. Receives the resolved `data` and returns + * `true` if the empty slot should be shown. + */ + isEmpty?: (data: TData) => boolean; + + /** + * Error slot. Function form gets the error + a `retry` callback so + * the same fallback can be reused across queries with different + * `refetch` references. + * + * Note: when `query.refetch` is `undefined`, `retry` is a no-op so + * the slot can render an unconditional "Спробувати ще" button. + */ + error?: ReactNode | ((error: TError, retry: () => void) => ReactNode); + + /** + * Stale slot — rendered alongside the children when fresh data is + * already on screen but a background refetch is in flight. Useful + * for unobtrusive "оновлюється…" badges that don't block content. + */ + stale?: (data: TData, isStale: boolean) => ReactNode; + + /** + * Body — receives the resolved `data` once the query is in the + * success state and not empty. + */ + children: (data: TData) => ReactNode; + + /** Outer wrapper class — applied around the rendered slot. */ + className?: string; +} + +const DEFAULT_EMPTY: (data: T) => boolean = (data) => { + if (data === undefined || data === null) return true; + if (Array.isArray(data)) return data.length === 0; + if (typeof data === "string") return data.length === 0; + return false; +}; + +/** + * Default error fallback — kept intentionally minimal. Pages that + * want module-tinted or shape-aware errors should pass a custom + * `error={(err, retry) => ...}` slot. + */ +function DefaultErrorFallback({ + error, + onRetry, +}: { + error: TError; + onRetry: () => void; +}) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Не вдалося завантажити дані."; + return ( +
+

Помилка

+

{message}

+ +
+ ); +} + +export function DataState({ + query, + skeleton, + empty, + isEmpty, + error, + stale, + children, + className, +}: DataStateProps) { + const { + data, + isLoading, + isPending, + isFetching, + isError, + error: queryError, + refetch, + } = query; + + const retry = (): void => { + refetch?.(); + }; + + // 1. **Error wins** — an explicit error short-circuits even if a + // cached `data` is present, because the cache may be stale and + // misleading. + const hasError = isError === true || queryError != null; + if (hasError) { + const err = queryError as TError; + const node = + typeof error === "function" + ? (error as (e: TError, r: () => void) => ReactNode)(err, retry) + : (error ?? ); + return
{node}
; + } + + // 2. **Loading** — only when we don't already have data. Falling + // back to React Query's `isPending` keeps support for v5 callers. + const loading = + (isLoading === true || isPending === true) && data === undefined; + if (loading) { + return ( +
+ {skeleton ?? } +
+ ); + } + + // 3. **Empty** — `data` resolved, but it's "nothing to show". We + // only render the empty slot when one is provided; otherwise we + // fall through to `children(data)` so callers can decide. + if (data !== undefined && empty !== undefined) { + const checker = (isEmpty ?? DEFAULT_EMPTY) as (d: TData) => boolean; + if (checker(data)) { + return
{empty}
; + } + } + + // 4. **Success** — render the body. If a `stale` slot is provided + // and a background refetch is happening (we have data AND + // `isFetching`), show it alongside the body. + if (data !== undefined) { + const isStale = isFetching === true; + return ( +
+ {stale ? stale(data, isStale) : null} + {children(data)} +
+ ); + } + + // 5. **Indeterminate** — neither error, nor loading, nor data. + // Render nothing so we don't flash an empty placeholder. + return null; +} diff --git a/apps/web/src/shared/components/ui/index.ts b/apps/web/src/shared/components/ui/index.ts index 6dc305be4..e85a681f1 100644 --- a/apps/web/src/shared/components/ui/index.ts +++ b/apps/web/src/shared/components/ui/index.ts @@ -149,6 +149,9 @@ export type { SkeletonProps } from "./Skeleton"; export { SkeletonCard, SkeletonList } from "./SkeletonCard"; export type { SkeletonCardProps, SkeletonListProps } from "./SkeletonCard"; +export { DataState } from "./DataState"; +export type { DataStateProps, DataStateQueryLike } from "./DataState"; + export { SkipLink } from "./SkipLink"; export type { SkipLinkProps } from "./SkipLink"; diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md index 5b0bfa363..b77f92259 100644 --- a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md +++ b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md @@ -1,6 +1,6 @@ # Web deep-dive — Overview -> **Last validated:** 2026-05-03 by @Skords-01. +> **Last validated:** 2026-05-04 by @Skords-01. > **Status:** Active > **Scope:** `apps/web` + `apps/server` + `packages/*` (mobile — лише дотичні точки). > **Related:** @@ -81,7 +81,7 @@ | 2 | Sentry tag `requestId` + UI shows on 5xx | 3 | 1 | 3.00 | [04 §4.4](./04-security-observability-testing-devx.md) — done [#1551](https://github.com/Skords-01/Sergeant/pull/1551) | | 3 | CSP report-only on Vercel | 3 | 1 | 3.00 | [04 §6.4](./04-security-observability-testing-devx.md) — done [#1551](https://github.com/Skords-01/Sergeant/pull/1551) | | 4 | Module prefetch on hover + on-idle | 3 | 1 | 3.00 | [03 §5.2 + §10.4](./03-backend-and-performance.md) — done (idle + connection gate) | -| 5 | `` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) | +| 5 | `` wrapper | 4 | 2 | 2.00 | [01 §3.2](./01-frontend-ergonomics.md) — done (component + 10 tests) | | 6 | `localStorage` 17 → 0 codemod | 4 | 2 | 2.00 | [02 §2.2](./02-architecture-and-state.md) | | 7 | Audit docs status-table + archive >6-mo | 2 | 1 | 2.00 | [04 §8.5](./04-security-observability-testing-devx.md) | | 8 | Form-engine unification | 5 | 3 | 1.67 | [01 §3.1](./01-frontend-ergonomics.md) | diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md b/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md index d8facdf4c..6e699910f 100644 --- a/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md +++ b/docs/diagnostics/2026-05-03-web-deep-dive/01-frontend-ergonomics.md @@ -1,6 +1,6 @@ # Web deep-dive — Frontend ergonomics & UX -> **Last validated:** 2026-05-03 by @Skords-01. +> **Last validated:** 2026-05-04 by @Skords-01. > **Status:** Active > **Scope:** Forms, loading/empty/error states, Toast, Modal, mobile safe-area, PWA install banner, auth error translation, i18n readiness, feature-reveal pattern. > **Related:** [`00-overview.md`](./00-overview.md), `docs/audits/UX-UI-AUDIT-2026.md`, `docs/audits/UX-IMPROVEMENT-PLAN.md`. @@ -54,6 +54,8 @@ ## 3.2 [Bad] Loading states — не бачу system-wide skeleton policy +> **2026-05-04 update.** Wrapper доданий: `` у `apps/web/src/shared/components/ui/DataState.tsx` + 10 contract tests у `DataState.test.tsx`. Precedence error → loading → empty → success зафіксовано тестом, slot-и описані у JSDoc на пропсах. Експорт через UI-barrel (`shared/components/ui/index.ts`). Наступний крок — refactor високотрафічних екранів (`MonoTransactionsPanel`, `BudgetPanel`, `RoutineList`) на цей wrapper в окремих PR-ах. + **Що бачу.** Є `ModulePageLoader.tsx`, `PageTransition.tsx`, окремі `... loading` тексти всередині сторінок. Кожна сторінка вирішує сама. Skeleton-component-library немає; немає й уніфікованої політики «коли skeleton, коли spinner, коли nothing». **Recommendation / fix points.**