@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84
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/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -16,7 +16,7 @@ For a Docker source build, pass `--build-arg CDN_BASE_URL=https://cdn.example.co
|
|
|
16
16
|
|
|
17
17
|
## Packing the build for a deployment
|
|
18
18
|
|
|
19
|
-
`pnpm build --tar` writes `storage/exports/dist.tar.gz` after the build. The archive holds `dist/` as a directory next to `config.example.yml`, so extracting it produces exactly those
|
|
19
|
+
`pnpm build --tar` writes `storage/exports/dist.tar.gz` after the build. The archive holds `dist/` as a directory next to `config.example.yml`, with the `Dockerfile` and `Dockerfile.dockerignore`, so extracting it produces exactly those paths rather than scattering `server/` and `node_modules/` into whatever directory you unpacked in, and a server can build the image from it alone with `docker build --build-arg DIST=prebuilt .`.
|
|
20
20
|
|
|
21
21
|
`config.example.yml` travels with it because a deployment has to write a `config.yml` before it can start — `pnpm nocobase config init`, run inside `dist/`, does that from the example, in place, and `pnpm nocobase config check` there verifies it, database connection included, before the first start — and the example is the only statement of what may go in it. Directories of executable shims are left out: a `.bin` entry points at a path on the machine that installed it, and a dangling one makes `pnpm install` in the extracted tree report a corrupt store rather than repair it.
|
|
22
22
|
|
package/.agents/rules/shell.md
CHANGED
|
@@ -33,3 +33,7 @@ Use `Loading` for page or panel waits and `Spinner` (the shadcn-compatible adapt
|
|
|
33
33
|
The shared list of shell behaviors to keep when customizing is in [customizing the shell](../skills/nocobase-app-development/references/frontend/references/shell.md).
|
|
34
34
|
|
|
35
35
|
The signed-in region waits in `client/routing/session-preparation.tsx` before mounting `AppLayout`: the authentication provider first reconnects active realtime subscriptions as the current session and waits for the open notification, including permission invalidation. A ten-second timeout shows a localized retry/sign-out screen. Keep this boundary inside `RequiredAuthentication`; guest login must remain reachable without realtime. Readiness is only for initial session preparation. Subsequent permission notifications and reconnects still use normal fail-closed route unmounting, including portal and effect cleanup.
|
|
36
|
+
|
|
37
|
+
## List context
|
|
38
|
+
|
|
39
|
+
Keep the identity-keyed `SessionPreparation` boundary: mounted private list conditions and ancestor query memory must be discarded when the signed-in user changes. Public list state uses `useTableUrlState`; tabbed datasets use `useListTabs`, with complete owned parameter keys and permission-filtered tabs. Follow the synchronized [list navigation rules](../skills/nocobase-app-development/references/frontend/references/child-routes.md#list-tab-breadcrumbs-and-private-conditions). Default back links use an empty ancestor query when no source visit exists; explicit return destinations remain application-owned.
|
package/AGENTS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Skills. You are building the application itself — its pages, its API, its database tables. Everything under this directory is application-owned source code that you may edit directly.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Build application-specific features directly in the application by default. Use a plugin when the user requests one or when code reuse or modular organization calls for a separately registered, maintainable feature. Local plugins belong under `packages/`; the App builds and packages registered local plugins for deployment, so publishing is optional and only needed when distributing the plugin through a package registry. Follow [the project plugin development Skill](.agents/skills/nocobase-app-plugin-development/SKILL.md). Ordinary internal packages can share code without plugin registration; follow [internal packages](.agents/rules/workspace.md) when that is the boundary the application needs.
|
|
6
6
|
|
|
7
7
|
## Default template scope
|
|
8
8
|
|
|
@@ -28,6 +28,8 @@ For creating or maintaining a plugin in this App, read [the project plugin devel
|
|
|
28
28
|
|
|
29
29
|
`.agents/skills/nocobase-app-upgrade/` is a separate Skill for a separate job: merging a newer release of the template this application was generated from. Read it when the task is upgrading the template rather than building a feature — including when the user asks to upgrade NocoBase or its core version, because a newer template release is how this application moves to a newer NocoBase — and read it before touching anything — an upgrade done by copying the newest template over this application destroys the work that made it this application.
|
|
30
30
|
|
|
31
|
+
When upgrading from an older installed Skill, also read the target template package’s `AGENTS.md` and `README.MD` and fetch its declared `@nocobase/app-skills` package before editing. Follow the target’s upgrade requirements in the same upgrade, including application-owned lists and installed Registry copies. Confirm the target baseline only after conflicts are resolved and dependency, source, behavior and retained-data checks pass; see the [upgrade Skill](.agents/skills/nocobase-app-upgrade/SKILL.md).
|
|
32
|
+
|
|
31
33
|
`.agents/skills/nocobase-deployment/` covers moving this application from source to a production server: building for the target platform, what the archive does and does not carry, migrations and business data, production configuration, and what to verify afterwards. Read it before building for deployment, and when an application starts in production but does not work.
|
|
32
34
|
|
|
33
35
|
## Where things go
|
|
@@ -118,6 +120,10 @@ When the built-in mechanism genuinely cannot express what is being asked, changi
|
|
|
118
120
|
- Comment what you changed and why the built-in path did not fit. On upgrade, an agent reconciling the template's version needs to know whether your change is still needed.
|
|
119
121
|
- Record what the next agent needs to know in the matching `.agents/rules/` file, or a new one linked from the index below, so the guidance describes the application as it actually is without growing this file. If the framework's shared guidance is wrong for every application, update the source `@nocobase/app-skills` package instead of editing its synchronized copy.
|
|
120
122
|
|
|
123
|
+
## Standard submissions
|
|
124
|
+
|
|
125
|
+
Standard Repository writes require an operation key through the registered submission provider. Forms share `useSubmission` across all entries before asynchronous validation; custom effectful APIs explicitly join the receipt transaction. Follow [submission protection](.agents/skills/nocobase-app-development/references/submission.md) and [source upgrades](.agents/skills/nocobase-app-upgrade/references/submission-upgrade.md). Merge copied source and verify retained customizations on upgrade.
|
|
126
|
+
|
|
121
127
|
## Index
|
|
122
128
|
|
|
123
129
|
| Read | When |
|
package/README.MD
CHANGED
|
@@ -4,6 +4,12 @@ A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` a
|
|
|
4
4
|
|
|
5
5
|
You own everything in this directory. Add pages, API routes, database tables, and business logic directly here — this is your application's source code, not a framework you extend from the outside.
|
|
6
6
|
|
|
7
|
+
## Upgrade this application
|
|
8
|
+
|
|
9
|
+
Ask the application agent to upgrade NocoBase using the application-local [template upgrade Skill](.agents/skills/nocobase-app-upgrade/SKILL.md). This is the supported version-selection and three-way source reconciliation entry point. Before merging, read the selected target package’s `AGENTS.md`, this section and the upgrade Skill from its declared `@nocobase/app-skills` dependency, even if the application still has an older Skill. Fetch and inspect that guidance without first installing it.
|
|
10
|
+
|
|
11
|
+
Complete the target’s requirements in that same upgrade: reconcile dependencies, copied template and Registry sources, application-owned table/card consumers and translations while preserving business customizations, grants, records and executed migrations. For list feedback, follow the target Skill’s `references/list-feedback-upgrade.md`. Do not finish with only a version bump, dependency update or Skill sync. Missing targets, unresolved business conflicts or failed acceptance stop completion; confirm `defaultTemplateVersion` only after the checks pass. See the target Skill’s `references/versioned-upgrade-verification.md` for package integrity records and verification after publication.
|
|
12
|
+
|
|
7
13
|
## Default and Examples
|
|
8
14
|
|
|
9
15
|
Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned business API routes start empty, and new databases receive no article sample data. A bare create-app shows Home and, for unrestricted administrators only, System settings → App foundation. Foundation is installed and registered on both Client and Server; its read-only overview is available at `/foundation` under the application base path. As business capabilities are developed, the app development skill reconciles missing core administration on every iteration and registers the prepared Users and Roles and permissions contribution (`client/pages/settings/routes.ts`); each actually used configurable capability includes its administration as part of feature delivery. Its built-in Users integration lists direct Authorization Permission Sets as application roles, while access assigned to all authenticated users remains separate. Use `app-template-examples` for article management and runnable plugin demonstrations.
|
|
@@ -138,7 +144,7 @@ The three files named `runtime.ts`, `plugins.ts`, and `routes.ts` are compositio
|
|
|
138
144
|
| `pnpm dev` | Start the API server and client with hot reload |
|
|
139
145
|
| `pnpm build` | Build the client and server into `dist/` |
|
|
140
146
|
| `pnpm start` | Run the built server; requires `pnpm build` first |
|
|
141
|
-
| `pnpm build --tar` | Build, then pack `dist
|
|
147
|
+
| `pnpm build --tar` | Build, then pack `dist/`, `config.example.yml` and the `Dockerfile` with its `.dockerignore` into `storage/exports/dist.tar.gz` |
|
|
142
148
|
|
|
143
149
|
### Database
|
|
144
150
|
|
|
@@ -287,10 +293,12 @@ Pass the file's path relative to `dist/client/`, with or without a leading slash
|
|
|
287
293
|
|
|
288
294
|
`pnpm build --tar` writes `storage/exports/dist.tar.gz`, the artifact `@nocobase/app-installer` installs on a server.
|
|
289
295
|
|
|
290
|
-
The archive has two levels: `config.example.yml` and `
|
|
296
|
+
The archive has two levels: `config.example.yml`, `dist/` and, when the application has them, the `Dockerfile` and `Dockerfile.dockerignore` sit at the root, and the build is inside `dist/`. A server builds the image from the extracted archive alone with `docker build --build-arg DIST=prebuilt .`. What `dist/` holds varies with the application; this is the shape:
|
|
291
297
|
|
|
292
298
|
```text
|
|
293
299
|
dist.tar.gz
|
|
300
|
+
├── Dockerfile
|
|
301
|
+
├── Dockerfile.dockerignore
|
|
294
302
|
├── config.example.yml
|
|
295
303
|
└── dist/
|
|
296
304
|
├── cli/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
2
|
import { ArrowLeftIcon } from 'lucide-react';
|
|
3
3
|
import type { ReactElement, ReactNode } from 'react';
|
|
4
|
-
import { Link, type To,
|
|
4
|
+
import { Link, type To, useResolvedPath } from 'react-router';
|
|
5
5
|
|
|
6
6
|
import { useRouteSearches } from '../routing/route-context.js';
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ import { cn } from 'cn';
|
|
|
10
10
|
export interface BackButtonProps {
|
|
11
11
|
/**
|
|
12
12
|
* Where it leads. Defaults to the parent route with its last visited query; a directly opened detail falls back
|
|
13
|
-
* to the
|
|
13
|
+
* to the default parent list. An explicit destination is kept unchanged.
|
|
14
14
|
*/
|
|
15
15
|
readonly to?: To;
|
|
16
16
|
/** The label, "Back" by default. */
|
|
@@ -33,7 +33,6 @@ export function BackButton({
|
|
|
33
33
|
to,
|
|
34
34
|
}: BackButtonProps = {}): ReactElement {
|
|
35
35
|
const { t } = useTranslation();
|
|
36
|
-
const location = useLocation();
|
|
37
36
|
const parent = useResolvedPath('..');
|
|
38
37
|
const searches = useRouteSearches();
|
|
39
38
|
|
|
@@ -48,8 +47,7 @@ export function BackButton({
|
|
|
48
47
|
to ?? {
|
|
49
48
|
pathname: '..',
|
|
50
49
|
search:
|
|
51
|
-
searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ??
|
|
52
|
-
location.search,
|
|
50
|
+
searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ?? '',
|
|
53
51
|
}
|
|
54
52
|
}
|
|
55
53
|
>
|
|
@@ -83,6 +83,7 @@ export function DefaultAccessEditor({
|
|
|
83
83
|
<Input
|
|
84
84
|
id={`${id}-key`}
|
|
85
85
|
name='ruleKey'
|
|
86
|
+
data-field-path='key'
|
|
86
87
|
value={ruleKey}
|
|
87
88
|
required
|
|
88
89
|
disabled={existing || readOnly}
|
|
@@ -91,7 +92,11 @@ export function DefaultAccessEditor({
|
|
|
91
92
|
onChange={(event) => onKeyChange(event.target.value)}
|
|
92
93
|
/>
|
|
93
94
|
{keyError && (
|
|
94
|
-
<p
|
|
95
|
+
<p
|
|
96
|
+
data-feedback-field-error
|
|
97
|
+
id={`${id}-key-error`}
|
|
98
|
+
className='text-sm text-destructive'
|
|
99
|
+
>
|
|
95
100
|
{keyError}
|
|
96
101
|
</p>
|
|
97
102
|
)}
|
|
@@ -108,6 +113,7 @@ export function DefaultAccessEditor({
|
|
|
108
113
|
>
|
|
109
114
|
<SelectTrigger
|
|
110
115
|
id={`${id}-resource`}
|
|
116
|
+
data-field-path='resource'
|
|
111
117
|
className='w-full'
|
|
112
118
|
aria-invalid={Boolean(resourceError)}
|
|
113
119
|
aria-describedby={
|
|
@@ -127,7 +133,11 @@ export function DefaultAccessEditor({
|
|
|
127
133
|
</SelectContent>
|
|
128
134
|
</Select>
|
|
129
135
|
{resourceError && (
|
|
130
|
-
<p
|
|
136
|
+
<p
|
|
137
|
+
data-feedback-field-error
|
|
138
|
+
id={`${id}-resource-error`}
|
|
139
|
+
className='text-sm text-destructive'
|
|
140
|
+
>
|
|
131
141
|
{resourceError}
|
|
132
142
|
</p>
|
|
133
143
|
)}
|
|
@@ -187,6 +197,7 @@ export function DefaultAccessEditor({
|
|
|
187
197
|
</Select>
|
|
188
198
|
{scope.error && (
|
|
189
199
|
<p
|
|
200
|
+
data-feedback-field-error
|
|
190
201
|
id={`${id}-${scope.id}-error`}
|
|
191
202
|
className='text-sm text-destructive'
|
|
192
203
|
>
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FormFeedback,
|
|
3
|
+
formFeedbackErrors,
|
|
4
|
+
type FormFeedbackAttempt,
|
|
5
|
+
type FormFeedbackField,
|
|
6
|
+
type FormFeedbackOptions,
|
|
7
|
+
type FormFeedbackState,
|
|
8
|
+
} from '@nocobase/app-client';
|
|
9
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
10
|
+
import {
|
|
11
|
+
useEffect,
|
|
12
|
+
useId,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useReducer,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
useSyncExternalStore,
|
|
19
|
+
type ComponentProps,
|
|
20
|
+
type ReactElement,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
} from 'react';
|
|
23
|
+
import { createPortal } from 'react-dom';
|
|
24
|
+
|
|
25
|
+
export interface FeedbackFormProps extends Omit<
|
|
26
|
+
ComponentProps<'form'>,
|
|
27
|
+
'onSubmit'
|
|
28
|
+
> {
|
|
29
|
+
readonly onSubmit?: (
|
|
30
|
+
event: React.FormEvent<HTMLFormElement>,
|
|
31
|
+
) => void | Promise<void>;
|
|
32
|
+
/** Pass the caught request error; input state continues to belong to the caller. */
|
|
33
|
+
readonly error?: unknown;
|
|
34
|
+
/** Trusted application copy/actions for a request failure; never pass raw server content. */
|
|
35
|
+
readonly errorContent?: ReactNode;
|
|
36
|
+
readonly submitting?: boolean;
|
|
37
|
+
/** For unmounted tabs, repeated rows and extension controls, register stable descriptors. */
|
|
38
|
+
readonly feedback?: FormFeedback;
|
|
39
|
+
readonly fieldMessage?: FormFeedbackOptions['fieldMessage'];
|
|
40
|
+
/** React Hook Form errors. Set shouldFocusError: false so layout order owns focus. */
|
|
41
|
+
readonly validationErrors?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Native fields use name (or data-field-path). Custom controls use a FormFeedback descriptor. */
|
|
45
|
+
export function FeedbackForm({
|
|
46
|
+
children,
|
|
47
|
+
onSubmit,
|
|
48
|
+
error,
|
|
49
|
+
errorContent,
|
|
50
|
+
feedback: supplied,
|
|
51
|
+
fieldMessage,
|
|
52
|
+
validationErrors,
|
|
53
|
+
noValidate,
|
|
54
|
+
submitting = false,
|
|
55
|
+
ref,
|
|
56
|
+
...props
|
|
57
|
+
}: FeedbackFormProps): ReactElement {
|
|
58
|
+
const { t } = useTranslation();
|
|
59
|
+
const options = useMemo<FormFeedbackOptions>(
|
|
60
|
+
() => ({
|
|
61
|
+
messages: {
|
|
62
|
+
field: t('formFeedback.field', {
|
|
63
|
+
defaultValue: 'This value was rejected. Check it and try again.',
|
|
64
|
+
}),
|
|
65
|
+
invalid: t('formFeedback.invalid', {
|
|
66
|
+
defaultValue: 'Check the highlighted fields and try again.',
|
|
67
|
+
}),
|
|
68
|
+
failed: t('formFeedback.failed', {
|
|
69
|
+
defaultValue: 'Unable to save. Your input has been kept. Try again.',
|
|
70
|
+
}),
|
|
71
|
+
network: t('formFeedback.network', {
|
|
72
|
+
defaultValue:
|
|
73
|
+
'Unable to connect. Check your connection and try again. Your input has been kept.',
|
|
74
|
+
}),
|
|
75
|
+
denied: t('formFeedback.denied', {
|
|
76
|
+
defaultValue:
|
|
77
|
+
'You cannot submit this form with your current access. Your input has been kept.',
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
...(fieldMessage ? { fieldMessage } : {}),
|
|
81
|
+
}),
|
|
82
|
+
[t, fieldMessage],
|
|
83
|
+
);
|
|
84
|
+
const [owned] = useState(() => new FormFeedback(options));
|
|
85
|
+
const feedback = supplied ?? owned;
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
feedback.configure(options);
|
|
88
|
+
}, [feedback, options]);
|
|
89
|
+
const state = useSyncExternalStore(
|
|
90
|
+
feedback.subscribe,
|
|
91
|
+
feedback.getSnapshot,
|
|
92
|
+
feedback.getSnapshot,
|
|
93
|
+
);
|
|
94
|
+
const [unfocusableState, setUnfocusableState] = useState<FormFeedbackState>();
|
|
95
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
96
|
+
const messageRef = useRef<HTMLDivElement>(null);
|
|
97
|
+
const attemptRef = useRef<FormFeedbackAttempt | undefined>(undefined);
|
|
98
|
+
const prefixId = useId();
|
|
99
|
+
const localValidationRef = useRef(false);
|
|
100
|
+
const focusPendingRef = useRef(false);
|
|
101
|
+
const controlsRef = useRef(
|
|
102
|
+
new Map<
|
|
103
|
+
HTMLElement,
|
|
104
|
+
{ field: FormFeedbackField; unregister: () => void }
|
|
105
|
+
>(),
|
|
106
|
+
);
|
|
107
|
+
const generatedRef = useRef(0);
|
|
108
|
+
const [, redraw] = useReducer((value: number) => value + 1, 0);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const element = formRef.current;
|
|
112
|
+
if (!element) return;
|
|
113
|
+
const registered = controlsRef.current;
|
|
114
|
+
const collect = () => {
|
|
115
|
+
for (const [control, entry] of registered)
|
|
116
|
+
if (!element.contains(control)) {
|
|
117
|
+
entry.unregister();
|
|
118
|
+
registered.delete(control);
|
|
119
|
+
}
|
|
120
|
+
for (const control of element.querySelectorAll<HTMLElement>(
|
|
121
|
+
'input[name], textarea[name], select[name], [data-field-path]',
|
|
122
|
+
)) {
|
|
123
|
+
if (registered.has(control)) continue;
|
|
124
|
+
// Application descriptors take precedence for custom/virtualized fields.
|
|
125
|
+
const path = () =>
|
|
126
|
+
control.dataset.fieldPath ?? control.getAttribute('name') ?? '';
|
|
127
|
+
if (feedback.hasPath(path())) continue;
|
|
128
|
+
const id = `${prefixId}-field-${++generatedRef.current}`;
|
|
129
|
+
const field: FormFeedbackField = {
|
|
130
|
+
id,
|
|
131
|
+
path,
|
|
132
|
+
label: () => fieldLabel(control),
|
|
133
|
+
anchor: () => control,
|
|
134
|
+
control: () => control,
|
|
135
|
+
value: () => {
|
|
136
|
+
if (control instanceof HTMLInputElement) {
|
|
137
|
+
if (control.type === 'file')
|
|
138
|
+
return Array.from(control.files ?? []);
|
|
139
|
+
if (control.type === 'checkbox' || control.type === 'radio')
|
|
140
|
+
return control.checked;
|
|
141
|
+
}
|
|
142
|
+
return 'value' in control ? control.value : control.textContent;
|
|
143
|
+
},
|
|
144
|
+
accessible: () =>
|
|
145
|
+
element.contains(control) &&
|
|
146
|
+
!control.closest(
|
|
147
|
+
'[data-feedback-hidden], [hidden], [inert], [aria-hidden="true"]',
|
|
148
|
+
) &&
|
|
149
|
+
ancestors(control).every((element) => {
|
|
150
|
+
const style = getComputedStyle(element);
|
|
151
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
152
|
+
}) &&
|
|
153
|
+
!(control instanceof HTMLInputElement && control.type === 'hidden'),
|
|
154
|
+
editable: () =>
|
|
155
|
+
!control.matches(':disabled, [aria-disabled="true"], [readonly]'),
|
|
156
|
+
reveal: () => {
|
|
157
|
+
for (const details of ancestors(control))
|
|
158
|
+
if (details instanceof HTMLDetailsElement) details.open = true;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
registered.set(control, {
|
|
162
|
+
field,
|
|
163
|
+
unregister: feedback.register(field),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
collect();
|
|
168
|
+
const observer = new MutationObserver(() => {
|
|
169
|
+
collect();
|
|
170
|
+
redraw();
|
|
171
|
+
});
|
|
172
|
+
observer.observe(element, {
|
|
173
|
+
childList: true,
|
|
174
|
+
subtree: true,
|
|
175
|
+
attributes: true,
|
|
176
|
+
attributeFilter: [
|
|
177
|
+
'hidden',
|
|
178
|
+
'inert',
|
|
179
|
+
'aria-hidden',
|
|
180
|
+
'disabled',
|
|
181
|
+
'data-feedback-hidden',
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
const change = (event: Event) => {
|
|
185
|
+
const control = event.target;
|
|
186
|
+
if (control instanceof HTMLElement) {
|
|
187
|
+
const entry = registered.get(control);
|
|
188
|
+
if (entry) feedback.clear(entry.field.id);
|
|
189
|
+
else feedback.clearControl(control);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
element.addEventListener('input', change);
|
|
193
|
+
element.addEventListener('change', change);
|
|
194
|
+
return () => {
|
|
195
|
+
observer.disconnect();
|
|
196
|
+
element.removeEventListener('input', change);
|
|
197
|
+
element.removeEventListener('change', change);
|
|
198
|
+
for (const entry of registered.values()) entry.unregister();
|
|
199
|
+
registered.clear();
|
|
200
|
+
};
|
|
201
|
+
}, [feedback, prefixId]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!error || !attemptRef.current) return;
|
|
205
|
+
if (feedback.getAttemptSequence() !== attemptRef.current.sequence) return;
|
|
206
|
+
focusPendingRef.current = true;
|
|
207
|
+
feedback.failure(error, attemptRef.current);
|
|
208
|
+
}, [error, feedback]);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (
|
|
212
|
+
(state.message || state.fields.length) &&
|
|
213
|
+
focusPendingRef.current &&
|
|
214
|
+
!submitting
|
|
215
|
+
) {
|
|
216
|
+
focusPendingRef.current = false;
|
|
217
|
+
void feedback.focus(messageRef.current, undefined, () => {
|
|
218
|
+
if (!state.message && feedback.getSnapshot() === state)
|
|
219
|
+
setUnfocusableState(state);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}, [state, feedback, submitting]);
|
|
223
|
+
|
|
224
|
+
// Existing controlled validators keep their messages and state. Adopt their invalid controls once per submission.
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (
|
|
227
|
+
validationErrors !== undefined ||
|
|
228
|
+
!localValidationRef.current ||
|
|
229
|
+
!attemptRef.current
|
|
230
|
+
)
|
|
231
|
+
return;
|
|
232
|
+
if (state.fields.length) {
|
|
233
|
+
localValidationRef.current = false;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const invalid = [...controlsRef.current.keys()].filter(
|
|
237
|
+
(control) => control.getAttribute('aria-invalid') === 'true',
|
|
238
|
+
);
|
|
239
|
+
if (!invalid.length) return;
|
|
240
|
+
localValidationRef.current = false;
|
|
241
|
+
focusPendingRef.current = true;
|
|
242
|
+
feedback.invalid(
|
|
243
|
+
invalid.map((control) => {
|
|
244
|
+
return {
|
|
245
|
+
field:
|
|
246
|
+
control.dataset.fieldPath ?? control.getAttribute('name') ?? '',
|
|
247
|
+
message:
|
|
248
|
+
(existingFieldError(control)?.textContent ||
|
|
249
|
+
options.messages.field) ??
|
|
250
|
+
options.messages.invalid,
|
|
251
|
+
};
|
|
252
|
+
}),
|
|
253
|
+
attemptRef.current,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const validationKey = JSON.stringify(formFeedbackErrors(validationErrors));
|
|
258
|
+
const attemptSequence = feedback.getAttemptSequence();
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (
|
|
261
|
+
!localValidationRef.current ||
|
|
262
|
+
submitting ||
|
|
263
|
+
!attemptSequence ||
|
|
264
|
+
!attemptRef.current
|
|
265
|
+
)
|
|
266
|
+
return;
|
|
267
|
+
localValidationRef.current = false;
|
|
268
|
+
if (validationKey === '[]') return;
|
|
269
|
+
const errors = formFeedbackErrors(validationErrors);
|
|
270
|
+
if (errors.length) {
|
|
271
|
+
focusPendingRef.current = true;
|
|
272
|
+
feedback.invalid(errors, attemptRef.current);
|
|
273
|
+
}
|
|
274
|
+
}, [validationErrors, validationKey, feedback, attemptSequence, submitting]);
|
|
275
|
+
|
|
276
|
+
const errors = feedback.errors();
|
|
277
|
+
const inaccessible = state.fields.length > 0 && errors.length === 0;
|
|
278
|
+
const formMessage =
|
|
279
|
+
state.message ||
|
|
280
|
+
(inaccessible || unfocusableState === state
|
|
281
|
+
? options.messages.failed
|
|
282
|
+
: undefined);
|
|
283
|
+
useLayoutEffect(() => {
|
|
284
|
+
if (inaccessible || unfocusableState === state) messageRef.current?.focus();
|
|
285
|
+
}, [state, inaccessible, unfocusableState]);
|
|
286
|
+
useLayoutEffect(() => {
|
|
287
|
+
const restore: (() => void)[] = [];
|
|
288
|
+
for (const entry of errors) {
|
|
289
|
+
const control = feedback.field(entry.id)?.control();
|
|
290
|
+
if (!control) continue;
|
|
291
|
+
const describedBy = control.getAttribute('aria-describedby');
|
|
292
|
+
const invalid = control.getAttribute('aria-invalid');
|
|
293
|
+
const ownsInvalid = invalid !== 'true';
|
|
294
|
+
const existing = existingFieldError(control);
|
|
295
|
+
const previousId = existing?.getAttribute('id');
|
|
296
|
+
const id = existing?.id || `${entry.id}-error`;
|
|
297
|
+
const ownsDescription = !(describedBy?.split(/\s+/) ?? []).includes(id);
|
|
298
|
+
if (existing && !existing.id) existing.id = id;
|
|
299
|
+
control.setAttribute('aria-invalid', 'true');
|
|
300
|
+
control.setAttribute(
|
|
301
|
+
'aria-describedby',
|
|
302
|
+
[...new Set([...(describedBy?.split(/\s+/) ?? []), id])]
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.join(' '),
|
|
305
|
+
);
|
|
306
|
+
restore.push(() => {
|
|
307
|
+
if (existing && previousId === null && existing.id === id)
|
|
308
|
+
existing.removeAttribute('id');
|
|
309
|
+
// React may already have committed new caller-owned attributes. Remove only our additions.
|
|
310
|
+
if (ownsDescription) {
|
|
311
|
+
const remaining = (control.getAttribute('aria-describedby') ?? '')
|
|
312
|
+
.split(/\s+/)
|
|
313
|
+
.filter((value) => value && value !== id);
|
|
314
|
+
if (remaining.length)
|
|
315
|
+
control.setAttribute('aria-describedby', remaining.join(' '));
|
|
316
|
+
else control.removeAttribute('aria-describedby');
|
|
317
|
+
}
|
|
318
|
+
if (ownsInvalid && control.getAttribute('aria-invalid') === 'true') {
|
|
319
|
+
if (invalid === null) control.removeAttribute('aria-invalid');
|
|
320
|
+
else control.setAttribute('aria-invalid', invalid);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return () => {
|
|
325
|
+
for (const reset of restore) reset();
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<form
|
|
331
|
+
{...props}
|
|
332
|
+
noValidate
|
|
333
|
+
ref={(element) => {
|
|
334
|
+
formRef.current = element;
|
|
335
|
+
if (typeof ref === 'function') return ref(element);
|
|
336
|
+
if (ref) ref.current = element;
|
|
337
|
+
}}
|
|
338
|
+
onSubmit={(event) => {
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
const current = feedback.begin();
|
|
341
|
+
attemptRef.current = current;
|
|
342
|
+
localValidationRef.current = true;
|
|
343
|
+
const invalid = [...event.currentTarget.elements].filter(
|
|
344
|
+
(
|
|
345
|
+
control,
|
|
346
|
+
): control is
|
|
347
|
+
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
348
|
+
(control instanceof HTMLInputElement ||
|
|
349
|
+
control instanceof HTMLSelectElement ||
|
|
350
|
+
control instanceof HTMLTextAreaElement) &&
|
|
351
|
+
!noValidate &&
|
|
352
|
+
control.willValidate &&
|
|
353
|
+
!control.validity.valid,
|
|
354
|
+
);
|
|
355
|
+
if (invalid.length) {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
focusPendingRef.current = true;
|
|
358
|
+
feedback.invalid(
|
|
359
|
+
invalid.map((control) => ({
|
|
360
|
+
field: control.dataset.fieldPath ?? control.name,
|
|
361
|
+
message: control.validationMessage,
|
|
362
|
+
})),
|
|
363
|
+
current,
|
|
364
|
+
);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const result = onSubmit?.(event);
|
|
369
|
+
if (result)
|
|
370
|
+
void result.catch((next: unknown) => {
|
|
371
|
+
if (feedback.getAttemptSequence() !== current.sequence) return;
|
|
372
|
+
focusPendingRef.current = true;
|
|
373
|
+
feedback.failure(next, current);
|
|
374
|
+
});
|
|
375
|
+
} catch (next) {
|
|
376
|
+
if (feedback.getAttemptSequence() !== current.sequence) return;
|
|
377
|
+
focusPendingRef.current = true;
|
|
378
|
+
feedback.failure(next, current);
|
|
379
|
+
}
|
|
380
|
+
}}
|
|
381
|
+
>
|
|
382
|
+
{(formMessage ||
|
|
383
|
+
(!attemptSequence && Boolean(error) && errorContent != null)) && (
|
|
384
|
+
<div
|
|
385
|
+
ref={messageRef}
|
|
386
|
+
role='alert'
|
|
387
|
+
tabIndex={-1}
|
|
388
|
+
className='space-y-2 text-sm text-destructive'
|
|
389
|
+
>
|
|
390
|
+
{error && errors.length === 0 && errorContent != null ? (
|
|
391
|
+
errorContent
|
|
392
|
+
) : (
|
|
393
|
+
<p>{formMessage}</p>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
{children}
|
|
398
|
+
{errors.map((entry) => {
|
|
399
|
+
const control = feedback.field(entry.id)?.control();
|
|
400
|
+
return control?.parentElement && !existingFieldError(control)
|
|
401
|
+
? createPortal(
|
|
402
|
+
<p
|
|
403
|
+
id={`${entry.id}-error`}
|
|
404
|
+
data-form-feedback-error
|
|
405
|
+
className='text-sm text-destructive'
|
|
406
|
+
>
|
|
407
|
+
{entry.message}
|
|
408
|
+
</p>,
|
|
409
|
+
control.parentElement,
|
|
410
|
+
entry.id,
|
|
411
|
+
)
|
|
412
|
+
: null;
|
|
413
|
+
})}
|
|
414
|
+
</form>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function ancestors(control: HTMLElement): HTMLElement[] {
|
|
419
|
+
const result: HTMLElement[] = [];
|
|
420
|
+
for (
|
|
421
|
+
let parent = control.parentElement;
|
|
422
|
+
parent;
|
|
423
|
+
parent = parent.parentElement
|
|
424
|
+
)
|
|
425
|
+
result.unshift(parent);
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Only explicitly associated application error nodes are trusted, never arbitrary nearby text. */
|
|
430
|
+
function existingFieldError(control: HTMLElement): HTMLElement | undefined {
|
|
431
|
+
const errorIds = (control.getAttribute('aria-errormessage') ?? '').split(
|
|
432
|
+
/\s+/,
|
|
433
|
+
);
|
|
434
|
+
const described = (control.getAttribute('aria-describedby') ?? '')
|
|
435
|
+
.split(/\s+/)
|
|
436
|
+
.map((id) => control.ownerDocument.getElementById(id))
|
|
437
|
+
.filter((node) =>
|
|
438
|
+
node?.matches('[data-slot="field-error"], [data-feedback-field-error]'),
|
|
439
|
+
);
|
|
440
|
+
const slot = control
|
|
441
|
+
.closest('[data-slot="field"]')
|
|
442
|
+
?.querySelector<HTMLElement>('[data-slot="field-error"]');
|
|
443
|
+
return [
|
|
444
|
+
...errorIds.map((id) => control.ownerDocument.getElementById(id)),
|
|
445
|
+
...described,
|
|
446
|
+
slot,
|
|
447
|
+
].find((node): node is HTMLElement =>
|
|
448
|
+
Boolean(node && !node.hasAttribute('data-form-feedback-error')),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Resolve explicit accessible names; group entry buttons may use their visible text. */
|
|
453
|
+
function fieldLabel(control: HTMLElement): string {
|
|
454
|
+
const labelledBy = (control.getAttribute('aria-labelledby') ?? '')
|
|
455
|
+
.split(/\s+/)
|
|
456
|
+
.map((id) => control.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
457
|
+
.filter(Boolean)
|
|
458
|
+
.join(' ');
|
|
459
|
+
if (labelledBy) return labelledBy;
|
|
460
|
+
const ariaLabel = control.getAttribute('aria-label')?.trim();
|
|
461
|
+
if (ariaLabel) return ariaLabel;
|
|
462
|
+
if ('labels' in control) {
|
|
463
|
+
const labels = Array.from(
|
|
464
|
+
(control as HTMLInputElement).labels ?? [],
|
|
465
|
+
(label) => label.textContent?.trim(),
|
|
466
|
+
)
|
|
467
|
+
.filter(Boolean)
|
|
468
|
+
.join(' ');
|
|
469
|
+
if (labels) return labels;
|
|
470
|
+
}
|
|
471
|
+
return control.matches('button, [role="button"]')
|
|
472
|
+
? (control.textContent?.trim() ?? '')
|
|
473
|
+
: '';
|
|
474
|
+
}
|