@tristan2828/ui-foundation 4.17.0 → 4.18.1
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 +1 -1
- package/conventions/docs/add-an-entity.md +18 -0
- package/dist/components/app/milkdown-editor.js +4 -2
- package/dist/components/app/row-edit-cells.js +7 -4
- package/dist/lib/link-href.d.ts +2 -0
- package/dist/lib/link-href.js +9 -1
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +25 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ than installing this by hand:
|
|
|
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
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` |
|
|
25
|
+
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady`, `expectDataViewsLoaded` |
|
|
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
28
|
| `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*`, `/environment` (optional) and the error bodies |
|
|
@@ -554,6 +554,24 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
554
554
|
one into `src/` to change it: raise it for the foundation (AGENTS.md
|
|
555
555
|
"Changing the Foundation") and build the screen once the release ships.
|
|
556
556
|
|
|
557
|
+
## After a commit, in the backend
|
|
558
|
+
|
|
559
|
+
A router reads who's signed in from the `User` that `get_current_user`
|
|
560
|
+
loaded in the request's own database session. `session.commit()` expires
|
|
561
|
+
every object in that session (`expire_on_commit`, on by default), the user
|
|
562
|
+
included, and reading an expired attribute is a lazy load, which under
|
|
563
|
+
asyncio is a `MissingGreenlet` and a 500.
|
|
564
|
+
|
|
565
|
+
- **After a commit, read by a `user_id` saved before it, never by the
|
|
566
|
+
`User`.** Take `user_id = user.id` at the top of the handler and use that
|
|
567
|
+
from then on; or `await session.refresh(user)` first, as `_start_session`
|
|
568
|
+
in `routers/auth.py` does to return it.
|
|
569
|
+
- **Test signed in for real.** The backend's `client` fixture
|
|
570
|
+
(`tests/conftest.py`) holds a `sessions` row and sends its cookie,
|
|
571
|
+
so the real `get_current_user` runs (`tests/signed_in.py`). Never stand
|
|
572
|
+
a `User(...)` in for `get_current_user` in an override: a detached user
|
|
573
|
+
never expires, so the test passes and the route fails live.
|
|
574
|
+
|
|
557
575
|
## Deleting
|
|
558
576
|
|
|
559
577
|
The plan's "Deleting" line says which of three. The Widget reference is the
|
|
@@ -54,7 +54,7 @@ import { Button } from '../ui/button.js';
|
|
|
54
54
|
import { Field, FieldError, FieldLabel } from '../ui/field.js';
|
|
55
55
|
import { Input } from '../ui/input.js';
|
|
56
56
|
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
57
|
-
import { linkHref } from '../../lib/link-href.js';
|
|
57
|
+
import { linkHref, linkRefusal } from '../../lib/link-href.js';
|
|
58
58
|
import { passEscapeThrough } from './escape-passthrough.js';
|
|
59
59
|
import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
|
|
60
60
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
@@ -751,7 +751,9 @@ function LinkForm({ box, onApply }) {
|
|
|
751
751
|
const apply = () => {
|
|
752
752
|
const href = linkHref(typed);
|
|
753
753
|
if (href === null) {
|
|
754
|
-
setError(
|
|
754
|
+
setError(linkRefusal(typed) === 'whitespace'
|
|
755
|
+
? 'An address has no spaces. Remove them, or write each as %20.'
|
|
756
|
+
: 'Enter a web or email address, like https://example.com.');
|
|
755
757
|
return;
|
|
756
758
|
}
|
|
757
759
|
onApply(href);
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ExternalLinkIcon } from 'lucide-react';
|
|
3
3
|
import { Input } from '../ui/input.js';
|
|
4
4
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
|
|
5
|
-
import { linkHref } from '../../lib/link-href.js';
|
|
5
|
+
import { linkHref, linkRefusal } from '../../lib/link-href.js';
|
|
6
6
|
import { editInPlace } from './edit-in-place.js';
|
|
7
7
|
import { EditableValue } from './editable-value.js';
|
|
8
8
|
/** A name in one line: Enter saves, the form's rule runs first, a taken name's 422 shows in the cell. */
|
|
@@ -31,14 +31,17 @@ export function ChoiceCell({ label, value, options, render, save, triggerClassNa
|
|
|
31
31
|
}), children: render(value) }));
|
|
32
32
|
}
|
|
33
33
|
// A web or email address (`example.com` gains https://), blank for none.
|
|
34
|
-
// Another scheme (`javascript:`)
|
|
34
|
+
// Another scheme (`javascript:`) or a space inside the address is refused
|
|
35
|
+
// before anything is sent.
|
|
35
36
|
const LINK_RULE = {
|
|
36
37
|
safeParse: (value) => {
|
|
37
|
-
const
|
|
38
|
+
const typed = String(value ?? '');
|
|
39
|
+
const href = linkHref(typed);
|
|
38
40
|
if (href === '')
|
|
39
41
|
return { success: true, data: null };
|
|
40
42
|
if (href === null) {
|
|
41
|
-
|
|
43
|
+
const message = linkRefusal(typed) === 'whitespace' ? 'An address has no spaces' : 'Use a web or email address';
|
|
44
|
+
return { success: false, error: { issues: [{ message }] } };
|
|
42
45
|
}
|
|
43
46
|
return { success: true, data: href };
|
|
44
47
|
},
|
package/dist/lib/link-href.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
2
2
|
export declare function linkHref(typed: string): string | null;
|
|
3
|
+
/** Why `linkHref` refused what was typed, for the message shown with it. */
|
|
4
|
+
export declare function linkRefusal(typed: string): 'whitespace' | 'scheme';
|
package/dist/lib/link-href.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// `https://example.com` and a bare `name@example.com` a `mailto:`; a path
|
|
4
4
|
// on this site (`/x`, never `//x`) or a heading (`#x`) is kept. Any other scheme
|
|
5
5
|
// (`javascript:`, `data:`, `file:`) is refused, so a link can't run code
|
|
6
|
-
// or reach a local file.
|
|
6
|
+
// or reach a local file. An address with whitespace inside it
|
|
7
|
+
// (`example wiki`, `https://a b`) is refused too: it isn't a working link,
|
|
8
|
+
// and adding https:// to two words would save one that looks like it is.
|
|
7
9
|
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
8
10
|
export function linkHref(typed) {
|
|
9
11
|
const value = typed.trim();
|
|
10
12
|
if (value === '')
|
|
11
13
|
return '';
|
|
14
|
+
if (/\s/.test(value))
|
|
15
|
+
return null;
|
|
12
16
|
if (/^(https?:\/\/|mailto:|tel:)/i.test(value))
|
|
13
17
|
return value;
|
|
14
18
|
// A scheme of its own (`javascript:`), not a port (`example.com:8080`).
|
|
@@ -24,3 +28,7 @@ export function linkHref(typed) {
|
|
|
24
28
|
return `mailto:${value}`;
|
|
25
29
|
return `https://${value}`;
|
|
26
30
|
}
|
|
31
|
+
/** Why `linkHref` refused what was typed, for the message shown with it. */
|
|
32
|
+
export function linkRefusal(typed) {
|
|
33
|
+
return /\s/.test(typed.trim()) ? 'whitespace' : 'scheme';
|
|
34
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -55,6 +55,18 @@ export type A11ySuiteOptions = {
|
|
|
55
55
|
*/
|
|
56
56
|
nav?: readonly NavEntry[];
|
|
57
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Every data view on the page has loaded: at least one shows its data (or,
|
|
60
|
+
* with `{ success: false }`, the page has its screen up), and none still
|
|
61
|
+
* shows its skeleton. A page may hold several (two `DataTable`s, an
|
|
62
|
+
* `EntityView` over a table of its sub-records), so this never waits on a
|
|
63
|
+
* locator that matches each of them: Playwright's strict mode refuses
|
|
64
|
+
* `expect(page.locator('[data-state="success"]')).toBeVisible()` as soon
|
|
65
|
+
* as two match. Use it before reading or scanning a screen.
|
|
66
|
+
*/
|
|
67
|
+
export declare function expectDataViewsLoaded(page: Page, { success }?: {
|
|
68
|
+
success?: boolean;
|
|
69
|
+
}): Promise<void>;
|
|
58
70
|
/**
|
|
59
71
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
60
72
|
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
package/dist/testing/index.js
CHANGED
|
@@ -107,11 +107,31 @@ const NAV_PAGE_BUDGET_MS = 10_000;
|
|
|
107
107
|
function navEntryRoutes(nav) {
|
|
108
108
|
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Every data view on the page has loaded: at least one shows its data (or,
|
|
112
|
+
* with `{ success: false }`, the page has its screen up), and none still
|
|
113
|
+
* shows its skeleton. A page may hold several (two `DataTable`s, an
|
|
114
|
+
* `EntityView` over a table of its sub-records), so this never waits on a
|
|
115
|
+
* locator that matches each of them: Playwright's strict mode refuses
|
|
116
|
+
* `expect(page.locator('[data-state="success"]')).toBeVisible()` as soon
|
|
117
|
+
* as two match. Use it before reading or scanning a screen.
|
|
118
|
+
*/
|
|
119
|
+
export async function expectDataViewsLoaded(page, { success = true } = {}) {
|
|
120
|
+
if (success)
|
|
121
|
+
await expect(page.locator('[data-state="success"]').first()).toBeVisible();
|
|
122
|
+
// A data view renders its skeleton from its first paint, so once the
|
|
123
|
+
// screen is up every one of them is either loading or settled.
|
|
124
|
+
else
|
|
125
|
+
await expect(page.getByRole('main').first()).toBeVisible();
|
|
126
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
127
|
+
}
|
|
110
128
|
async function expectNavPageAccessible(page, route, colorScheme) {
|
|
111
129
|
await page.goto(route);
|
|
112
130
|
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
113
|
-
// Wait for data, not the loading skeleton, so the real UI is checked.
|
|
114
|
-
|
|
131
|
+
// Wait for data, not the loading skeleton, so the real UI is checked. A
|
|
132
|
+
// nav page may have no data view, or only an empty one, so none has to
|
|
133
|
+
// show data.
|
|
134
|
+
await expectDataViewsLoaded(page, { success: false });
|
|
115
135
|
await expectNoAxeViolations(page);
|
|
116
136
|
}
|
|
117
137
|
/**
|
|
@@ -163,8 +183,9 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes,
|
|
|
163
183
|
for (const route of viewRoutes) {
|
|
164
184
|
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
165
185
|
await page.goto(route);
|
|
166
|
-
// EntityView's loaded state, not its skeleton or an error
|
|
167
|
-
|
|
186
|
+
// EntityView's loaded state, not its skeleton or an error, and
|
|
187
|
+
// every other data view on the page loaded too.
|
|
188
|
+
await expectDataViewsLoaded(page);
|
|
168
189
|
await expectNoAxeViolations(page);
|
|
169
190
|
});
|
|
170
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.18.1",
|
|
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": {
|