@tristan2828/ui-foundation 3.18.1 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/conventions/AGENTS.md +2 -2
- package/conventions/docs/add-an-entity.md +1 -1
- package/dist/api/gateway/environment.d.ts +1 -0
- package/dist/api/gateway/environment.js +10 -0
- package/dist/api/schema.d.ts +45 -0
- package/dist/auth/auth-provider.js +11 -7
- package/dist/components/app/data-environment-banner.d.ts +5 -0
- package/dist/components/app/data-environment-banner.js +28 -0
- package/dist/components/app/foundation-providers.js +2 -0
- package/dist/hooks/use-data-label.d.ts +3 -0
- package/dist/hooks/use-data-label.js +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mocks/environment.d.ts +1 -0
- package/dist/mocks/environment.js +8 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/dist/testing/index.d.ts +26 -0
- package/dist/testing/index.js +80 -0
- package/openapi/foundation.yaml +31 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,14 +18,14 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
|
-
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
25
|
-
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
|
|
24
|
+
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
25
|
+
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
|
|
26
26
|
| `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
|
|
27
27
|
| `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
|
|
28
|
-
| `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth
|
|
28
|
+
| `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*`, `/environment` (optional) and the error bodies |
|
|
29
29
|
|
|
30
30
|
## The `ui-foundation` command
|
|
31
31
|
|
package/conventions/AGENTS.md
CHANGED
|
@@ -37,8 +37,8 @@ is right. Do not disable, skip, or work around it.
|
|
|
37
37
|
- NEVER hand-write an API type. All types come from `src/api/schema.d.ts`,
|
|
38
38
|
generated from `openapi.yaml`. If a type is missing, run `npm run gen:api`.
|
|
39
39
|
(Enforced: codegen diff in verify.)
|
|
40
|
-
- NEVER change the foundation's part of `openapi.yaml` — the `/auth/*`
|
|
41
|
-
and the error envelopes. The package's code calls exactly those shapes.
|
|
40
|
+
- NEVER change the foundation's part of `openapi.yaml` — the `/auth/*` and
|
|
41
|
+
`/environment` paths and the error envelopes. The package's code calls exactly those shapes.
|
|
42
42
|
(Enforced: `ui-foundation check-contract` in verify.)
|
|
43
43
|
- NEVER use a raw hex value or a Tailwind palette color (`bg-blue-500`).
|
|
44
44
|
Semantic tokens only: `bg-primary`, `text-muted-foreground`. (Enforced:
|
|
@@ -68,7 +68,7 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
68
68
|
query parameter for each field the plan marks as a filter. Reuse the
|
|
69
69
|
`Page` and error components already in the spec; do not redefine
|
|
70
70
|
pagination or error shapes per entity, and leave the foundation's part
|
|
71
|
-
of the spec (`/auth/*`, the error bodies) alone — `ui-foundation
|
|
71
|
+
of the spec (`/auth/*`, `/environment`, the error bodies) alone — `ui-foundation
|
|
72
72
|
check-contract` fails verify if it changes.
|
|
73
73
|
2. **`npm run gen:api`** to regenerate `src/api/schema.d.ts`. Never
|
|
74
74
|
hand-edit it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getDataLabel(): Promise<string | null>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { safeFetch, toAppError } from "./errors.js";
|
|
2
|
+
export async function getDataLabel() {
|
|
3
|
+
const res = await safeFetch("/environment");
|
|
4
|
+
if (res.status === 404)
|
|
5
|
+
return null;
|
|
6
|
+
if (res.status !== 200)
|
|
7
|
+
throw toAppError(res.status, res.body);
|
|
8
|
+
const label = res.body?.dataLabel;
|
|
9
|
+
return typeof label === "string" && label.trim() !== "" ? label.trim() : null;
|
|
10
|
+
}
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -71,10 +71,34 @@ export interface paths {
|
|
|
71
71
|
patch?: never;
|
|
72
72
|
trace?: never;
|
|
73
73
|
};
|
|
74
|
+
"/environment": {
|
|
75
|
+
parameters: {
|
|
76
|
+
query?: never;
|
|
77
|
+
header?: never;
|
|
78
|
+
path?: never;
|
|
79
|
+
cookie?: never;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Which data the backend is serving, for DataEnvironmentBanner
|
|
83
|
+
* @description Needs no session: the banner shows on /login too. A null dataLabel means production data, and no banner.
|
|
84
|
+
*/
|
|
85
|
+
get: operations["getDataEnvironment"];
|
|
86
|
+
put?: never;
|
|
87
|
+
post?: never;
|
|
88
|
+
delete?: never;
|
|
89
|
+
options?: never;
|
|
90
|
+
head?: never;
|
|
91
|
+
patch?: never;
|
|
92
|
+
trace?: never;
|
|
93
|
+
};
|
|
74
94
|
}
|
|
75
95
|
export type webhooks = Record<string, never>;
|
|
76
96
|
export interface components {
|
|
77
97
|
schemas: {
|
|
98
|
+
DataEnvironment: {
|
|
99
|
+
/** @description What the data is, as the banner names it ("dev" shows "Dev data"). null when the backend serves production data. */
|
|
100
|
+
dataLabel: string | null;
|
|
101
|
+
};
|
|
78
102
|
User: {
|
|
79
103
|
readonly id: number;
|
|
80
104
|
/** Format: email */
|
|
@@ -243,4 +267,25 @@ export interface operations {
|
|
|
243
267
|
500: components["responses"]["ServerError"];
|
|
244
268
|
};
|
|
245
269
|
};
|
|
270
|
+
getDataEnvironment: {
|
|
271
|
+
parameters: {
|
|
272
|
+
query?: never;
|
|
273
|
+
header?: never;
|
|
274
|
+
path?: never;
|
|
275
|
+
cookie?: never;
|
|
276
|
+
};
|
|
277
|
+
requestBody?: never;
|
|
278
|
+
responses: {
|
|
279
|
+
/** @description OK. */
|
|
280
|
+
200: {
|
|
281
|
+
headers: {
|
|
282
|
+
[name: string]: unknown;
|
|
283
|
+
};
|
|
284
|
+
content: {
|
|
285
|
+
"application/json": components["schemas"]["DataEnvironment"];
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
500: components["responses"]["ServerError"];
|
|
289
|
+
};
|
|
290
|
+
};
|
|
246
291
|
}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { getCurrentUser, login as loginRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
|
|
5
|
+
import { DATA_LABEL_QUERY_KEY } from '../hooks/use-data-label.js';
|
|
5
6
|
import { AuthContext } from './auth-context.js';
|
|
6
7
|
// TanStack Query, not useEffect — AGENTS.md's "NEVER fetch in useEffect"
|
|
7
8
|
// rule applies here too. A 401 here just means "not logged in", which the
|
|
@@ -11,14 +12,17 @@ const AUTH_QUERY_KEY = ['auth', 'me'];
|
|
|
11
12
|
function isAuthError(error) {
|
|
12
13
|
return error?.kind === 'auth';
|
|
13
14
|
}
|
|
14
|
-
// Every cached query except
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
15
|
+
// Every cached query except these belongs to whoever was logged in when it
|
|
16
|
+
// ran: the session itself, and which data the backend serves
|
|
17
|
+
// (DataEnvironmentBanner's, the same for everyone).
|
|
18
|
+
const SESSION_INDEPENDENT_KEYS = new Set([AUTH_QUERY_KEY[0], DATA_LABEL_QUERY_KEY[0]]);
|
|
19
|
+
// Dropped on every session change so the next user never sees the
|
|
20
|
+
// previous user's rows — widgets are per-user on the backend.
|
|
21
|
+
// `removeQueries` with a predicate, not `clear()`: clear() would also drop
|
|
22
|
+
// the auth query this provider's own useQuery observes, and that observer
|
|
23
|
+
// wouldn't pick up the replacement setQueryData creates.
|
|
20
24
|
function removeUserScopedQueries(queryClient) {
|
|
21
|
-
queryClient.removeQueries({ predicate: (query) => query.queryKey[0]
|
|
25
|
+
queryClient.removeQueries({ predicate: (query) => !SESSION_INDEPENDENT_KEYS.has(query.queryKey[0]) });
|
|
22
26
|
}
|
|
23
27
|
function startSession(queryClient, user) {
|
|
24
28
|
removeUserScopedQueries(queryClient);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function DataEnvironmentBanner(): import("react").JSX.Element;
|
|
2
|
+
/** The banner itself, for a label already known (Storybook, tests). Renders nothing for null. */
|
|
3
|
+
export declare function DataEnvironmentNotice({ label }: {
|
|
4
|
+
label: string | null;
|
|
5
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DatabaseIcon } from 'lucide-react';
|
|
3
|
+
import { useDataLabel } from '../../hooks/use-data-label.js';
|
|
4
|
+
// The real backend, on data that isn't production's: a dev branch of the
|
|
5
|
+
// hosted database, a local one. Locally the app looks exactly like the
|
|
6
|
+
// deployed one (same data, same login), and an edit made there lands in
|
|
7
|
+
// data that gets reset. Only the backend knows which database it's on, so
|
|
8
|
+
// the label is its DATA_LABEL, served from GET /environment. Unset in
|
|
9
|
+
// production: no label, no banner.
|
|
10
|
+
//
|
|
11
|
+
// Rendered above the router, inside FoundationProviders (it queries
|
|
12
|
+
// through TanStack Query), so it shows on every route including /login.
|
|
13
|
+
// In mock mode the mock handler answers null: MockModeBanner already says
|
|
14
|
+
// what the data is there.
|
|
15
|
+
export function DataEnvironmentBanner() {
|
|
16
|
+
const label = useDataLabel();
|
|
17
|
+
return _jsx(DataEnvironmentNotice, { label: label });
|
|
18
|
+
}
|
|
19
|
+
/** The banner itself, for a label already known (Storybook, tests). Renders nothing for null. */
|
|
20
|
+
export function DataEnvironmentNotice({ label }) {
|
|
21
|
+
if (label === null)
|
|
22
|
+
return null;
|
|
23
|
+
return (_jsxs("div", { role: "status", "data-slot": "data-environment-banner", className: "flex h-9 items-center justify-center gap-1.5 border-b border-border bg-info px-4 text-sm text-info-foreground", children: [_jsx(DatabaseIcon, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { className: "truncate", children: [_jsx("strong", { className: "font-semibold", children: `${dataName(label)}:` }), " changes here don't reach production"] })] }));
|
|
24
|
+
}
|
|
25
|
+
// "dev" → "Dev data", "staging" → "Staging data".
|
|
26
|
+
function dataName(label) {
|
|
27
|
+
return `${label.charAt(0).toUpperCase()}${label.slice(1)} data`;
|
|
28
|
+
}
|
|
@@ -9,6 +9,8 @@ import { ThemeProvider } from '../theme-provider.js';
|
|
|
9
9
|
// works: the theme outermost, the mock-mode banner above the router so it
|
|
10
10
|
// shows on every route (/login included), and AuthProvider inside
|
|
11
11
|
// QueryClientProvider because it queries /auth/me through TanStack Query.
|
|
12
|
+
// An app's DataEnvironmentBanner goes first among the children, before its
|
|
13
|
+
// router: it queries too, and shows on every route like the mock banner.
|
|
12
14
|
export function FoundationProviders({ mockMode, queryClient, children, }) {
|
|
13
15
|
const [client] = useState(() => queryClient ?? createQueryClient());
|
|
14
16
|
return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: children }) })] }));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { getDataLabel } from '../api/gateway/environment.js';
|
|
3
|
+
// Which data the backend serves (GET /environment), for
|
|
4
|
+
// DataEnvironmentBanner. It can't change while the page is open, so it's
|
|
5
|
+
// asked once per load: never stale, never refetched on focus. And it's the
|
|
6
|
+
// same whoever is signed in, so AuthProvider keeps it across session
|
|
7
|
+
// changes instead of dropping it with the user's rows.
|
|
8
|
+
export const DATA_LABEL_QUERY_KEY = ['environment'];
|
|
9
|
+
/** The backend's data label ("dev"), or null for production data, a failed request, or while it loads. */
|
|
10
|
+
export function useDataLabel() {
|
|
11
|
+
const { data } = useQuery({
|
|
12
|
+
queryKey: DATA_LABEL_QUERY_KEY,
|
|
13
|
+
queryFn: getDataLabel,
|
|
14
|
+
staleTime: Infinity,
|
|
15
|
+
gcTime: Infinity,
|
|
16
|
+
});
|
|
17
|
+
return data ?? null;
|
|
18
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { DataTable, type DataTableProps, type SortingState } from './components/
|
|
|
3
3
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
4
|
export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
5
5
|
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
|
|
6
|
+
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
6
7
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
7
8
|
export { ErrorState } from './components/app/error-state.js';
|
|
8
9
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { DataTable } from './components/app/data-table.js';
|
|
|
8
8
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
9
|
export { EntityView, } from './components/app/entity-view.js';
|
|
10
10
|
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
11
|
+
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
11
12
|
export { EditableValue } from './components/app/editable-value.js';
|
|
12
13
|
export { ErrorState } from './components/app/error-state.js';
|
|
13
14
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const environmentHandlers: import("msw").HttpHandler[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// MSW handler for GET /environment (openapi/foundation.yaml). Mock data is
|
|
2
|
+
// never production's, but MockModeBanner already says so: answering null
|
|
3
|
+
// keeps DataEnvironmentBanner out of mock mode. An app that renders the
|
|
4
|
+
// banner spreads `environmentHandlers` into its own handler list.
|
|
5
|
+
import { http, HttpResponse } from 'msw';
|
|
6
|
+
export const environmentHandlers = [
|
|
7
|
+
http.get('*/api/environment', () => HttpResponse.json({ dataLabel: null })),
|
|
8
|
+
];
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
2
|
+
export { environmentHandlers } from './environment.js';
|
|
2
3
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
3
4
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -2,4 +2,5 @@
|
|
|
2
2
|
// so msw (an optional peer dependency) never reaches an app's main bundle
|
|
3
3
|
// unless it imports this.
|
|
4
4
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
|
+
export { environmentHandlers } from './environment.js';
|
|
5
6
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -73,3 +73,29 @@ export type MockModeBannerSuiteOptions = {
|
|
|
73
73
|
* were production, which is what the banner exists for.
|
|
74
74
|
*/
|
|
75
75
|
export declare function defineMockModeBannerSuite({ routes, mockMode, }: MockModeBannerSuiteOptions): void;
|
|
76
|
+
export type DataEnvironmentBannerSuiteOptions = {
|
|
77
|
+
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
78
|
+
routes: readonly string[];
|
|
79
|
+
/** Whether this run's app is MSW-backed. Defaults to `process.env.VITE_API !== 'real'`, as for the mock-mode banner. */
|
|
80
|
+
mockMode?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Against the real backend only: the DATA_LABEL it was started with.
|
|
83
|
+
* Defaults to `process.env.DATA_LABEL`, null when that's unset or empty
|
|
84
|
+
* (production data, so no banner).
|
|
85
|
+
*/
|
|
86
|
+
dataLabel?: string | null;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* DataEnvironmentBanner: shown on every route, logged-out ones included,
|
|
90
|
+
* whenever the backend says its data isn't production's, and nowhere when
|
|
91
|
+
* it says it is.
|
|
92
|
+
*
|
|
93
|
+
* Mock mode: the mock handler answers null, so no banner (MockModeBanner
|
|
94
|
+
* covers mock data). A label forced through MSW shows on every route, in
|
|
95
|
+
* a tone apart from the mock banner, and passes axe in light and dark.
|
|
96
|
+
*
|
|
97
|
+
* Real backend (`VITE_API=real`): the banner names `dataLabel` on every
|
|
98
|
+
* route, or is absent when it's null, which is how a production deploy
|
|
99
|
+
* runs.
|
|
100
|
+
*/
|
|
101
|
+
export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, }: DataEnvironmentBannerSuiteOptions): void;
|
package/dist/testing/index.js
CHANGED
|
@@ -154,3 +154,83 @@ export function defineMockModeBannerSuite({ routes, mockMode = process.env.VITE_
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
|
+
// The banner names the label as data: "dev" → "Dev data".
|
|
158
|
+
const dataName = (label) => `${label.charAt(0).toUpperCase()}${label.slice(1)} data`;
|
|
159
|
+
/**
|
|
160
|
+
* Opens a route and waits for the app's own GET /environment to answer,
|
|
161
|
+
* so "no banner" is checked after the backend has spoken, not before.
|
|
162
|
+
*/
|
|
163
|
+
async function gotoWithDataLabel(page, route) {
|
|
164
|
+
const answered = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/environment');
|
|
165
|
+
await page.goto(route);
|
|
166
|
+
await answered;
|
|
167
|
+
// The banner renders beside the router: once a screen's <main> is on
|
|
168
|
+
// the page, the banner would be too.
|
|
169
|
+
await expect(page.getByRole('main')).toBeVisible();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* DataEnvironmentBanner: shown on every route, logged-out ones included,
|
|
173
|
+
* whenever the backend says its data isn't production's, and nowhere when
|
|
174
|
+
* it says it is.
|
|
175
|
+
*
|
|
176
|
+
* Mock mode: the mock handler answers null, so no banner (MockModeBanner
|
|
177
|
+
* covers mock data). A label forced through MSW shows on every route, in
|
|
178
|
+
* a tone apart from the mock banner, and passes axe in light and dark.
|
|
179
|
+
*
|
|
180
|
+
* Real backend (`VITE_API=real`): the banner names `dataLabel` on every
|
|
181
|
+
* route, or is absent when it's null, which is how a production deploy
|
|
182
|
+
* runs.
|
|
183
|
+
*/
|
|
184
|
+
export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, }) {
|
|
185
|
+
const banner = (page) => page.getByRole('status').filter({ hasText: "changes here don't reach production" });
|
|
186
|
+
const expectAbsentEverywhere = async (page) => {
|
|
187
|
+
for (const route of routes) {
|
|
188
|
+
await gotoWithDataLabel(page, route);
|
|
189
|
+
await test.step(route, () => expect(banner(page)).toHaveCount(0));
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const expectNamedEverywhere = async (page, label) => {
|
|
193
|
+
for (const route of routes) {
|
|
194
|
+
await gotoWithDataLabel(page, route);
|
|
195
|
+
await test.step(route, () => expect(banner(page)).toContainText(`${dataName(label)}:`));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
if (!mockMode) {
|
|
199
|
+
if (dataLabel === null) {
|
|
200
|
+
test('no data-environment banner on production data', async ({ page }) => {
|
|
201
|
+
await expectAbsentEverywhere(page);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
test(`"${dataName(dataLabel)}" is announced on every route, including logged-out ones`, async ({ page }) => {
|
|
206
|
+
await expectNamedEverywhere(page, dataLabel);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const forceLabel = (page) => forceMswOverride(page, { method: 'get', path: '*/api/environment', body: { dataLabel: 'dev' } });
|
|
212
|
+
test('no data-environment banner in mock mode, where the mock banner says it', async ({ page }) => {
|
|
213
|
+
await expectAbsentEverywhere(page);
|
|
214
|
+
});
|
|
215
|
+
test('a data label from the backend is announced on every route, including logged-out ones', async ({ page }) => {
|
|
216
|
+
await forceLabel(page);
|
|
217
|
+
await expectNamedEverywhere(page, 'dev');
|
|
218
|
+
// Both banners at once, and never the same one.
|
|
219
|
+
await expect(page.getByRole('status').filter({ hasText: 'Mock data' })).toBeVisible();
|
|
220
|
+
const fill = (locator) => locator.evaluate((element) => getComputedStyle(element).backgroundColor);
|
|
221
|
+
expect(await fill(banner(page))).not.toBe(await fill(page.getByRole('status').filter({ hasText: 'Mock data' })));
|
|
222
|
+
});
|
|
223
|
+
for (const colorScheme of ['light', 'dark']) {
|
|
224
|
+
test(`the data-environment banner has zero axe violations (${colorScheme})`, async ({ page }) => {
|
|
225
|
+
await page.emulateMedia({ colorScheme });
|
|
226
|
+
await forceLabel(page);
|
|
227
|
+
for (const route of routes) {
|
|
228
|
+
await gotoWithDataLabel(page, route);
|
|
229
|
+
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
230
|
+
await expect(banner(page)).toBeVisible();
|
|
231
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
232
|
+
await test.step(route, () => expectNoAxeViolations(page));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
package/openapi/foundation.yaml
CHANGED
|
@@ -12,8 +12,9 @@ info:
|
|
|
12
12
|
may not.
|
|
13
13
|
|
|
14
14
|
A path marked `x-optional: true` may be left out of an app's spec (an
|
|
15
|
-
app without self-service sign-up omits /auth/register
|
|
16
|
-
|
|
15
|
+
app without self-service sign-up omits /auth/register, an app that
|
|
16
|
+
doesn't render DataEnvironmentBanner omits /environment), but if
|
|
17
|
+
present it must match.
|
|
17
18
|
|
|
18
19
|
servers:
|
|
19
20
|
- url: /api
|
|
@@ -83,8 +84,36 @@ paths:
|
|
|
83
84
|
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
84
85
|
"500": { $ref: "#/components/responses/ServerError" }
|
|
85
86
|
|
|
87
|
+
/environment:
|
|
88
|
+
x-optional: true
|
|
89
|
+
get:
|
|
90
|
+
operationId: getDataEnvironment
|
|
91
|
+
summary: Which data the backend is serving, for DataEnvironmentBanner
|
|
92
|
+
description: >
|
|
93
|
+
Needs no session: the banner shows on /login too. A null dataLabel
|
|
94
|
+
means production data, and no banner.
|
|
95
|
+
tags: [environment]
|
|
96
|
+
responses:
|
|
97
|
+
"200":
|
|
98
|
+
description: OK.
|
|
99
|
+
content:
|
|
100
|
+
application/json:
|
|
101
|
+
schema: { $ref: "#/components/schemas/DataEnvironment" }
|
|
102
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
103
|
+
|
|
86
104
|
components:
|
|
87
105
|
schemas:
|
|
106
|
+
DataEnvironment:
|
|
107
|
+
type: object
|
|
108
|
+
required: [dataLabel]
|
|
109
|
+
properties:
|
|
110
|
+
dataLabel:
|
|
111
|
+
type: ["string", "null"]
|
|
112
|
+
minLength: 1
|
|
113
|
+
description: >
|
|
114
|
+
What the data is, as the banner names it ("dev" shows "Dev
|
|
115
|
+
data"). null when the backend serves production data.
|
|
116
|
+
|
|
88
117
|
User:
|
|
89
118
|
type: object
|
|
90
119
|
required: [id, email, name]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|