mfeflow 0.2.0 → 0.3.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.
Files changed (71) hide show
  1. package/assets/features/analytics-clarity/feature.json +64 -0
  2. package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
  3. package/assets/features/analytics-clarity/files/clarity.ts +26 -0
  4. package/assets/features/analytics-gtm/files/init.ts +2 -2
  5. package/assets/features/commit-prompt/feature.json +27 -0
  6. package/assets/features/commit-prompt/files/cz.config.js +27 -0
  7. package/assets/features/data-react-query/feature.json +89 -0
  8. package/assets/features/data-react-query/files/keys.ts.eta +8 -0
  9. package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
  10. package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
  11. package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
  12. package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
  13. package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
  14. package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
  15. package/assets/features/env-t3/feature.json +21 -0
  16. package/assets/features/forms/feature.json +90 -0
  17. package/assets/features/forms/files/ContactForm-test.tsx +35 -0
  18. package/assets/features/forms/files/ContactForm.tsx +50 -0
  19. package/assets/features/forms/files/schema.arktype.ts +9 -0
  20. package/assets/features/forms/files/schema.valibot.ts +9 -0
  21. package/assets/features/forms/files/schema.zod.ts +9 -0
  22. package/assets/features/i18n/feature.json +139 -0
  23. package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
  24. package/assets/features/i18n/files/Languages.tsx +12 -0
  25. package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
  26. package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
  27. package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
  28. package/assets/features/i18n/files/remote-ar.json.eta +3 -0
  29. package/assets/features/i18n/files/remote-config.ts +9 -0
  30. package/assets/features/i18n/files/remote-en.json.eta +3 -0
  31. package/assets/features/i18n/files/remote-load.ts +4 -0
  32. package/assets/features/i18n/files/shell-config.ts +20 -0
  33. package/assets/features/i18n/files/shell-init-test.ts +32 -0
  34. package/assets/features/i18n/files/shell-init.ts +7 -0
  35. package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
  36. package/assets/features/lint-oxlint/files/prettierignore +1 -0
  37. package/assets/features/storybook/feature.json +119 -0
  38. package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
  39. package/assets/features/storybook/files/Home.stories.tsx +9 -0
  40. package/assets/features/storybook/files/button.stories.tsx +14 -0
  41. package/assets/features/storybook/files/remote-main.ts +13 -0
  42. package/assets/features/storybook/files/rsbuild.config.ts +10 -0
  43. package/assets/features/storybook/files/shell-main.ts +39 -0
  44. package/assets/features/tailwind/feature.json +87 -0
  45. package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
  46. package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
  47. package/assets/features/tailwind/files/postcss.config.mjs +1 -0
  48. package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
  49. package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
  50. package/assets/features/testing/files/vitest.config.ts +11 -2
  51. package/assets/features/theming/feature.json +103 -0
  52. package/assets/features/theming/files/Theme.tsx +15 -0
  53. package/assets/features/theming/files/ThemeToggle.tsx +23 -0
  54. package/assets/features/theming/files/apply-stored-test.ts +37 -0
  55. package/assets/features/theming/files/apply-stored.ts +13 -0
  56. package/assets/features/theming/files/head-script.ts +8 -0
  57. package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
  58. package/assets/features/theming/files/theme.css +14 -0
  59. package/assets/features/theming/files/themes.ts +30 -0
  60. package/assets/features/theming/files/tokens-test.ts +9 -0
  61. package/assets/features/theming/files/tokens.ts +9 -0
  62. package/assets/features/ui-shadcn/feature.json +61 -0
  63. package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
  64. package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
  65. package/assets/features/ui-shadcn/files/components.json.eta +22 -0
  66. package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
  67. package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
  68. package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
  69. package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
  70. package/dist/index.js +45 -0
  71. package/package.json +4 -4
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "analytics-clarity",
3
+ "version": "0.1.0",
4
+ "description": "Microsoft Clarity behind @mfeflow/analytics, next to Google Tag Manager. Clarity loads only after the visitor grants analytics consent in the banner analytics-gtm adds, and stops again if they withdraw it. The project id is the runtime config's clarityId.",
5
+ "requires": ["core", "tsconfig", "analytics-gtm"],
6
+ "files": [
7
+ {
8
+ "to": "src/analytics/clarity.ts",
9
+ "target": "shell",
10
+ "from": "files/clarity.ts"
11
+ },
12
+ {
13
+ "to": "src/analytics/clarity.test.ts",
14
+ "target": "shell",
15
+ "from": "files/clarity-test.ts",
16
+ "when": {
17
+ "feature:testing": true
18
+ }
19
+ }
20
+ ],
21
+ "transforms": [
22
+ {
23
+ "op": "add-boot-step",
24
+ "file": "src/bootstrap.tsx",
25
+ "target": "shell",
26
+ "call": "initClarityFromConfig",
27
+ "from": "./analytics/clarity",
28
+ "await": false
29
+ },
30
+ {
31
+ "op": "add-env-key",
32
+ "file": "src/config/schema.ts",
33
+ "target": "shell",
34
+ "key": "clarityId",
35
+ "scope": "runtime",
36
+ "schema": {
37
+ "zod": "z.string().regex(/^([a-z0-9]{8,12})?$/)",
38
+ "valibot": "v.pipe(v.string(), v.regex(/^([a-z0-9]{8,12})?$/))",
39
+ "arktype": "'/^([a-z0-9]{8,12})?$/'"
40
+ },
41
+ "value": "",
42
+ "configFiles": [
43
+ "public/config/runtime-config.dev.json",
44
+ "public/config/runtime-config.staging.json",
45
+ "public/config/runtime-config.prod.json"
46
+ ],
47
+ "when": {
48
+ "feature:env-t3": true
49
+ }
50
+ },
51
+ {
52
+ "op": "add-env-key",
53
+ "file": "public/config/runtime-config.json",
54
+ "target": "shell",
55
+ "key": "clarityId",
56
+ "scope": "runtime",
57
+ "schema": "z.string().regex(/^([a-z0-9]{8,12})?$/)",
58
+ "value": "",
59
+ "when": {
60
+ "feature:env-t3": false
61
+ }
62
+ }
63
+ ]
64
+ }
@@ -0,0 +1,36 @@
1
+ import { providerName, resetAnalyticsForTests, updateConsent } from '@mfeflow/analytics';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { startClarity } from './clarity';
4
+
5
+ const clarityScript = () => document.querySelector('script[src*="clarity.ms/tag/"]');
6
+
7
+ afterEach(() => {
8
+ Reflect.deleteProperty(globalThis, '__MFE_RUNTIME_CONFIG__');
9
+ Reflect.deleteProperty(globalThis, 'clarity');
10
+ document.head.innerHTML = '';
11
+ resetAnalyticsForTests();
12
+ });
13
+
14
+ describe('startClarity', () => {
15
+ it('stays off without a clarityId', async () => {
16
+ Reflect.set(globalThis, '__MFE_RUNTIME_CONFIG__', { clarityId: '' });
17
+ await startClarity();
18
+ expect(providerName()).toBe('noop');
19
+ });
20
+
21
+ it('stays off when the id is not a Clarity project id', async () => {
22
+ Reflect.set(globalThis, '__MFE_RUNTIME_CONFIG__', { clarityId: 'GTM-ABC123' });
23
+ await startClarity();
24
+ expect(providerName()).toBe('noop');
25
+ });
26
+
27
+ it('waits for consent, then loads the script once', async () => {
28
+ Reflect.set(globalThis, '__MFE_RUNTIME_CONFIG__', { clarityId: 'k3x9m2abcd' });
29
+ await startClarity();
30
+ await startClarity();
31
+ expect(providerName()).toBe('clarity');
32
+ expect(clarityScript()).toBeNull();
33
+ updateConsent({ analytics_storage: 'granted' });
34
+ expect(document.querySelectorAll('script[src*="clarity.ms/tag/k3x9m2abcd"]')).toHaveLength(1);
35
+ });
36
+ });
@@ -0,0 +1,26 @@
1
+ import { addProvider } from '@mfeflow/analytics/providers';
2
+ import { createClarityProvider } from '@mfeflow/analytics/clarity';
3
+ import { fetchRuntimeConfig } from '@mfeflow/runtime';
4
+
5
+ /**
6
+ * Adds Clarity next to the other analytics providers, from the runtime config's `clarityId`. With no id it
7
+ * does nothing. Even with one, Clarity requests nothing until the visitor grants analytics consent.
8
+ */
9
+ export async function startClarity(): Promise<void> {
10
+ const config = await fetchRuntimeConfig().catch((error: unknown) => {
11
+ console.warn('[analytics] the runtime config did not load, so Clarity stays off', error);
12
+ return undefined;
13
+ });
14
+ const clarityId = config?.clarityId;
15
+ if (typeof clarityId !== 'string' || clarityId === '') return;
16
+ try {
17
+ addProvider(createClarityProvider({ projectId: clarityId }));
18
+ } catch (error) {
19
+ console.warn('[analytics] Clarity not started', error);
20
+ }
21
+ }
22
+
23
+ /** The boot step: never holds up the first render. */
24
+ export function initClarityFromConfig(): void {
25
+ void startClarity();
26
+ }
@@ -1,4 +1,4 @@
1
- import { initAnalytics } from '@mfeflow/analytics';
1
+ import { addProvider } from '@mfeflow/analytics/providers';
2
2
  import { createGtmProvider } from '@mfeflow/analytics/gtm';
3
3
  import { fetchRuntimeConfig } from '@mfeflow/runtime';
4
4
 
@@ -14,7 +14,7 @@ export async function startAnalytics(): Promise<void> {
14
14
  const gtmId = config?.gtmId;
15
15
  if (typeof gtmId !== 'string' || gtmId === '') return;
16
16
  try {
17
- initAnalytics(createGtmProvider({ containerId: gtmId }));
17
+ addProvider(createGtmProvider({ containerId: gtmId }));
18
18
  } catch (error) {
19
19
  console.warn('[analytics] not started', error);
20
20
  }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "commit-prompt",
3
+ "version": "0.1.0",
4
+ "description": "An interactive Conventional Commits prompt (czg): pnpm commit asks for the type, scope and message, then runs git commit. No emoji. Works with git-hooks, whose commitlint hook checks the same message.",
5
+ "requires": ["core"],
6
+ "files": [
7
+ {
8
+ "to": "cz.config.js",
9
+ "target": "root",
10
+ "from": "files/cz.config.js"
11
+ }
12
+ ],
13
+ "dependencies": [
14
+ {
15
+ "name": "czg",
16
+ "range": "1.14.0",
17
+ "target": "root",
18
+ "dev": true
19
+ }
20
+ ],
21
+ "scripts": [
22
+ {
23
+ "name": "commit",
24
+ "command": "czg"
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,27 @@
1
+ // `pnpm commit` asks for each part of a Conventional Commit, then runs `git commit`.
2
+ // The types are the ones commitlint's conventional config accepts, so the git-hooks check passes.
3
+ export default {
4
+ useEmoji: false,
5
+ // commitlint's conventional config rejects a capitalised subject, so the prompt lowercases it.
6
+ upperCaseSubject: false,
7
+ allowCustomScopes: true,
8
+ allowEmptyScopes: true,
9
+ allowBreakingChanges: ['feat', 'fix'],
10
+ maxHeaderLength: 100,
11
+ // Offered as a list; anything else can be typed. Add your apps and areas.
12
+ scopes: ['deps', 'ci'],
13
+ skipQuestions: ['footerPrefix'],
14
+ types: [
15
+ { value: 'feat', name: 'feat: A new feature' },
16
+ { value: 'fix', name: 'fix: A bug fix' },
17
+ { value: 'docs', name: 'docs: Documentation only' },
18
+ { value: 'style', name: 'style: Formatting, no change to behaviour' },
19
+ { value: 'refactor', name: 'refactor: Neither a fix nor a feature' },
20
+ { value: 'perf', name: 'perf: Makes it faster' },
21
+ { value: 'test', name: 'test: Add or fix tests' },
22
+ { value: 'build', name: 'build: Build system or dependencies' },
23
+ { value: 'ci', name: 'ci: CI configuration' },
24
+ { value: 'chore', name: 'chore: Anything else that is not in src or tests' },
25
+ { value: 'revert', name: 'revert: Revert an earlier commit' },
26
+ ],
27
+ };
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "data-react-query",
3
+ "version": "0.1.0",
4
+ "description": "Server state with TanStack Query as a shared singleton: the shell owns the QueryClient and its provider, every remote calls useQuery against the same cache, and each remote prefixes its query keys with its own name. The example query uses the http client when http is installed.",
5
+ "requires": ["core", "tsconfig"],
6
+ "files": [
7
+ {
8
+ "to": "src/query/Query.tsx",
9
+ "target": "shell",
10
+ "from": "files/shell-Query.tsx"
11
+ },
12
+ {
13
+ "to": "src/query/Query.test.tsx",
14
+ "target": "shell",
15
+ "from": "files/shell-Query-test.tsx",
16
+ "when": {
17
+ "feature:testing": true
18
+ }
19
+ },
20
+ {
21
+ "to": "src/query/Query.tsx",
22
+ "target": "remotes",
23
+ "from": "files/remote-Query.tsx"
24
+ },
25
+ {
26
+ "to": "src/query/keys.ts",
27
+ "target": "remotes",
28
+ "from": "files/keys.ts.eta",
29
+ "template": true
30
+ },
31
+ {
32
+ "to": "src/query/useHealth.ts",
33
+ "target": "remotes",
34
+ "from": "files/useHealth.http.ts.eta",
35
+ "template": true,
36
+ "when": {
37
+ "feature:http": true
38
+ }
39
+ },
40
+ {
41
+ "to": "src/query/useHealth.ts",
42
+ "target": "remotes",
43
+ "from": "files/useHealth.fetch.ts.eta",
44
+ "template": true,
45
+ "when": {
46
+ "feature:http": false
47
+ }
48
+ },
49
+ {
50
+ "to": "src/query/Query.test.tsx",
51
+ "target": "remotes",
52
+ "from": "files/remote-Query-test.tsx.eta",
53
+ "template": true,
54
+ "when": {
55
+ "feature:testing": true
56
+ }
57
+ }
58
+ ],
59
+ "dependencies": [
60
+ {
61
+ "name": "@tanstack/react-query",
62
+ "range": "5.105.0",
63
+ "target": "shell"
64
+ },
65
+ {
66
+ "name": "@tanstack/react-query",
67
+ "range": "5.105.0",
68
+ "target": "remotes"
69
+ }
70
+ ],
71
+ "transforms": [
72
+ {
73
+ "op": "wrap-provider",
74
+ "file": "src/App.tsx",
75
+ "target": "shell",
76
+ "component": "Query",
77
+ "from": "./query/Query",
78
+ "function": "App"
79
+ },
80
+ {
81
+ "op": "wrap-provider",
82
+ "file": "src/exposed/App.tsx",
83
+ "target": "remotes",
84
+ "component": "Query",
85
+ "from": "../query/Query",
86
+ "function": "App"
87
+ }
88
+ ]
89
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Every query key of this remote starts with its name, so it never meets another remote's key in the shared cache,
3
+ * and `queryClient.invalidateQueries({ queryKey: queryKeys.all })` refreshes just this remote.
4
+ */
5
+ export const queryKeys = {
6
+ all: ['<%= it.app.name %>'] as const,
7
+ health: () => [...queryKeys.all, 'health'] as const,
8
+ };
@@ -0,0 +1,39 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { queryKeys } from './keys';
5
+ import { Query } from './Query';
6
+ import { useHealth } from './useHealth';
7
+
8
+ function Health() {
9
+ const { data } = useHealth();
10
+ return <p>{data ? `ok: ${String(data.ok)}` : 'loading'}</p>;
11
+ }
12
+
13
+ describe('the <%= it.app.name %> queries', () => {
14
+ it('start with the remote name', () => {
15
+ expect(queryKeys.health()[0]).toBe('<%= it.app.name %>');
16
+ });
17
+
18
+ it('run on their own, without the shell', async () => {
19
+ render(
20
+ <Query>
21
+ <Health />
22
+ </Query>,
23
+ );
24
+ expect(await screen.findByText('ok: true')).toBeDefined();
25
+ });
26
+
27
+ it('use the shell client when there is one', async () => {
28
+ const shell = new QueryClient();
29
+ render(
30
+ <QueryClientProvider client={shell}>
31
+ <Query>
32
+ <Health />
33
+ </Query>
34
+ </QueryClientProvider>,
35
+ );
36
+ await screen.findByText('ok: true');
37
+ expect(shell.getQueryData(queryKeys.health())).toEqual({ ok: true });
38
+ });
39
+ });
@@ -0,0 +1,15 @@
1
+ import { useContext, type ReactNode } from 'react';
2
+ import { QueryClient, QueryClientContext, QueryClientProvider } from '@tanstack/react-query';
3
+
4
+ let standalone: QueryClient | undefined;
5
+
6
+ /**
7
+ * Inside the shell this does nothing: the shell's client is already in context. When the remote runs on its own
8
+ * (`pnpm dev` in its folder) there is no shell, so it provides a client of its own.
9
+ */
10
+ export function Query({ children }: { children: ReactNode }) {
11
+ const inShell = useContext(QueryClientContext) !== undefined;
12
+ if (inShell) return children;
13
+ standalone ??= new QueryClient();
14
+ return <QueryClientProvider client={standalone}>{children}</QueryClientProvider>;
15
+ }
@@ -0,0 +1,21 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { Query, queryClient } from './Query';
5
+
6
+ function Value() {
7
+ const { data } = useQuery({ queryKey: ['shell', 'answer'], queryFn: () => Promise.resolve(42) });
8
+ return <p>{data ?? 'loading'}</p>;
9
+ }
10
+
11
+ describe('Query', () => {
12
+ it('gives the app one cache', async () => {
13
+ render(
14
+ <Query>
15
+ <Value />
16
+ </Query>,
17
+ );
18
+ expect(await screen.findByText('42')).toBeDefined();
19
+ expect(queryClient.getQueryData(['shell', 'answer'])).toBe(42);
20
+ });
21
+ });
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+
4
+ /**
5
+ * The page's one cache. Remotes call `useQuery` against it because `@tanstack/react-query` is a shared
6
+ * singleton, so they find this client through context. Tune the defaults here.
7
+ */
8
+ export const queryClient = new QueryClient({
9
+ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } },
10
+ });
11
+
12
+ export function Query({ children }: { children: ReactNode }) {
13
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
14
+ }
@@ -0,0 +1,15 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { queryKeys } from './keys';
3
+
4
+ /** An example query with plain fetch. With the `http` feature this file uses its client instead. */
5
+ export function useHealth() {
6
+ return useQuery({
7
+ queryKey: queryKeys.health(),
8
+ queryFn: async ({ signal }) => {
9
+ const response = await fetch(new URL('/api/health', location.origin), { signal });
10
+ if (!response.ok) throw new Error(`health check failed (${String(response.status)})`);
11
+ const body: unknown = await response.json();
12
+ return { ok: typeof body === 'object' && body !== null && Reflect.get(body, 'ok') === true };
13
+ },
14
+ });
15
+ }
@@ -0,0 +1,11 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { api } from '../api/client';
3
+ import { queryKeys } from './keys';
4
+
5
+ /** An example query: the API's health check through the http client, cancelled when the component unmounts. */
6
+ export function useHealth() {
7
+ return useQuery({
8
+ queryKey: queryKeys.health(),
9
+ queryFn: ({ signal }) => api.get('health', { signal }),
10
+ });
11
+ }
@@ -394,6 +394,27 @@
394
394
  "when": {
395
395
  "feature:http": true
396
396
  }
397
+ },
398
+ {
399
+ "op": "add-env-key",
400
+ "file": "src/config/schema.ts",
401
+ "target": "shell",
402
+ "key": "clarityId",
403
+ "scope": "runtime",
404
+ "schema": {
405
+ "zod": "z.string().regex(/^([a-z0-9]{8,12})?$/)",
406
+ "valibot": "v.pipe(v.string(), v.regex(/^([a-z0-9]{8,12})?$/))",
407
+ "arktype": "'/^([a-z0-9]{8,12})?$/'"
408
+ },
409
+ "value": "",
410
+ "configFiles": [
411
+ "public/config/runtime-config.dev.json",
412
+ "public/config/runtime-config.staging.json",
413
+ "public/config/runtime-config.prod.json"
414
+ ],
415
+ "when": {
416
+ "feature:analytics-clarity": true
417
+ }
397
418
  }
398
419
  ]
399
420
  }
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "forms",
3
+ "version": "0.1.0",
4
+ "description": "Forms with react-hook-form, validated by a schema (zod, valibot or arktype, through the Standard Schema resolver). Each remote gets an example form with its own schema; nothing is shared between remotes and nothing is added to the shell.",
5
+ "requires": ["core", "tsconfig"],
6
+ "prompts": [
7
+ {
8
+ "name": "validator",
9
+ "type": "select",
10
+ "message": "Which schema library?",
11
+ "choices": ["zod", "valibot", "arktype"],
12
+ "default": "zod"
13
+ }
14
+ ],
15
+ "files": [
16
+ {
17
+ "to": "src/forms/schema.ts",
18
+ "target": "remotes",
19
+ "from": "files/schema.zod.ts",
20
+ "when": {
21
+ "validator": "zod"
22
+ }
23
+ },
24
+ {
25
+ "to": "src/forms/schema.ts",
26
+ "target": "remotes",
27
+ "from": "files/schema.valibot.ts",
28
+ "when": {
29
+ "validator": "valibot"
30
+ }
31
+ },
32
+ {
33
+ "to": "src/forms/schema.ts",
34
+ "target": "remotes",
35
+ "from": "files/schema.arktype.ts",
36
+ "when": {
37
+ "validator": "arktype"
38
+ }
39
+ },
40
+ {
41
+ "to": "src/forms/ContactForm.tsx",
42
+ "target": "remotes",
43
+ "from": "files/ContactForm.tsx"
44
+ },
45
+ {
46
+ "to": "src/forms/ContactForm.test.tsx",
47
+ "target": "remotes",
48
+ "from": "files/ContactForm-test.tsx",
49
+ "when": {
50
+ "feature:testing": true
51
+ }
52
+ }
53
+ ],
54
+ "dependencies": [
55
+ {
56
+ "name": "react-hook-form",
57
+ "range": "7.91.0",
58
+ "target": "remotes"
59
+ },
60
+ {
61
+ "name": "@hookform/resolvers",
62
+ "range": "7.0.1",
63
+ "target": "remotes"
64
+ },
65
+ {
66
+ "name": "zod",
67
+ "range": "4.6.7",
68
+ "target": "remotes",
69
+ "when": {
70
+ "validator": "zod"
71
+ }
72
+ },
73
+ {
74
+ "name": "valibot",
75
+ "range": "1.5.0",
76
+ "target": "remotes",
77
+ "when": {
78
+ "validator": "valibot"
79
+ }
80
+ },
81
+ {
82
+ "name": "arktype",
83
+ "range": "2.2.9",
84
+ "target": "remotes",
85
+ "when": {
86
+ "validator": "arktype"
87
+ }
88
+ }
89
+ ]
90
+ }
@@ -0,0 +1,35 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { ContactForm } from './ContactForm';
4
+ import type { ContactValues } from './schema';
5
+
6
+ function fill(label: string, value: string) {
7
+ fireEvent.change(screen.getByLabelText(label), { target: { value } });
8
+ }
9
+
10
+ describe('ContactForm', () => {
11
+ it('shows what is wrong and does not submit', async () => {
12
+ const onSubmit = vi.fn();
13
+ render(<ContactForm onSubmit={onSubmit} />);
14
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
15
+ expect((await screen.findAllByRole('alert')).length).toBe(3);
16
+ expect(onSubmit).not.toHaveBeenCalled();
17
+ });
18
+
19
+ it('submits the values once, even when the button is clicked twice', async () => {
20
+ const onSubmit = vi.fn(
21
+ (_values: ContactValues) => new Promise<void>((resolve) => setTimeout(resolve, 50)),
22
+ );
23
+ render(<ContactForm onSubmit={onSubmit} />);
24
+ fill('Name', 'Asha');
25
+ fill('Email', 'asha@example.com');
26
+ fill('Message', 'Hello, this is long enough.');
27
+ const button = screen.getByRole('button', { name: 'Send' });
28
+ fireEvent.click(button);
29
+ fireEvent.click(button);
30
+ await waitFor(() => {
31
+ expect(onSubmit).toHaveBeenCalledTimes(1);
32
+ });
33
+ expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ name: 'Asha', email: 'asha@example.com' });
34
+ });
35
+ });
@@ -0,0 +1,50 @@
1
+ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
2
+ import { useForm } from 'react-hook-form';
3
+ import { contactSchema, type ContactValues } from './schema';
4
+
5
+ interface ContactFormProps {
6
+ /** Called with the validated values. The button stays disabled until the promise settles, so a double click sends once. */
7
+ onSubmit: (values: ContactValues) => void | Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * An example to copy from. Load it where you need it with `lazy(() => import('./forms/ContactForm').then((m) => ({ default: m.ContactForm })))`,
12
+ * so the form library and the schema stay out of the remote's first load.
13
+ */
14
+ export function ContactForm({ onSubmit }: ContactFormProps) {
15
+ const {
16
+ register,
17
+ handleSubmit,
18
+ formState: { errors, isSubmitting },
19
+ } = useForm<ContactValues>({
20
+ resolver: standardSchemaResolver(contactSchema),
21
+ defaultValues: { name: '', email: '', message: '' },
22
+ });
23
+ return (
24
+ <form
25
+ noValidate
26
+ onSubmit={(event) => {
27
+ void handleSubmit(onSubmit)(event);
28
+ }}
29
+ >
30
+ <label>
31
+ Name
32
+ <input {...register('name')} aria-invalid={errors.name ? 'true' : 'false'} />
33
+ </label>
34
+ {errors.name ? <p role="alert">{errors.name.message}</p> : null}
35
+ <label>
36
+ Email
37
+ <input type="email" {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />
38
+ </label>
39
+ {errors.email ? <p role="alert">{errors.email.message}</p> : null}
40
+ <label>
41
+ Message
42
+ <textarea {...register('message')} aria-invalid={errors.message ? 'true' : 'false'} />
43
+ </label>
44
+ {errors.message ? <p role="alert">{errors.message.message}</p> : null}
45
+ <button type="submit" disabled={isSubmitting}>
46
+ Send
47
+ </button>
48
+ </form>
49
+ );
50
+ }
@@ -0,0 +1,9 @@
1
+ import { type } from 'arktype';
2
+
3
+ export const contactSchema = type({
4
+ name: 'string > 0',
5
+ email: 'string.email',
6
+ message: 'string >= 10',
7
+ });
8
+
9
+ export type ContactValues = typeof contactSchema.infer;
@@ -0,0 +1,9 @@
1
+ import * as v from 'valibot';
2
+
3
+ export const contactSchema = v.object({
4
+ name: v.pipe(v.string(), v.minLength(1, 'Enter your name')),
5
+ email: v.pipe(v.string(), v.email('Enter a valid email address')),
6
+ message: v.pipe(v.string(), v.minLength(10, 'Say a little more (at least 10 characters)')),
7
+ });
8
+
9
+ export type ContactValues = v.InferOutput<typeof contactSchema>;
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+
3
+ export const contactSchema = z.object({
4
+ name: z.string().min(1, 'Enter your name'),
5
+ email: z.email('Enter a valid email address'),
6
+ message: z.string().min(10, 'Say a little more (at least 10 characters)'),
7
+ });
8
+
9
+ export type ContactValues = z.infer<typeof contactSchema>;