mfeflow 0.1.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.
- package/assets/features/analytics-clarity/feature.json +64 -0
- package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
- package/assets/features/analytics-clarity/files/clarity.ts +26 -0
- package/assets/features/analytics-gtm/files/init.ts +2 -2
- package/assets/features/ci-github/files/ci.md +1 -1
- package/assets/features/ci-github/files/ci.yml +4 -4
- package/assets/features/commit-prompt/feature.json +27 -0
- package/assets/features/commit-prompt/files/cz.config.js +27 -0
- package/assets/features/data-react-query/feature.json +89 -0
- package/assets/features/data-react-query/files/keys.ts.eta +8 -0
- package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
- package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
- package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
- package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
- package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
- package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
- package/assets/features/env-t3/feature.json +42 -0
- package/assets/features/env-t3/files/env.arktype.ts +1 -1
- package/assets/features/env-t3/files/env.valibot.ts +1 -1
- package/assets/features/env-t3/files/env.zod.ts +1 -1
- package/assets/features/env-t3/files/schema.arktype.ts +1 -1
- package/assets/features/env-t3/files/schema.valibot.ts +1 -1
- package/assets/features/env-t3/files/schema.zod.ts +1 -1
- package/assets/features/event-bus/feature.json +100 -0
- package/assets/features/event-bus/files/announce.tsx.eta +10 -0
- package/assets/features/event-bus/files/contracts-events.ts +11 -0
- package/assets/features/event-bus/files/contracts-package.json.eta +10 -0
- package/assets/features/event-bus/files/contracts-readme.md +10 -0
- package/assets/features/event-bus/files/events-test.ts.eta +18 -0
- package/assets/features/event-bus/files/events.ts.eta +20 -0
- package/assets/features/event-bus/files/shell-events-test.ts.eta +22 -0
- package/assets/features/event-bus/files/shell-events.ts.eta +19 -0
- package/assets/features/forms/feature.json +90 -0
- package/assets/features/forms/files/ContactForm-test.tsx +35 -0
- package/assets/features/forms/files/ContactForm.tsx +50 -0
- package/assets/features/forms/files/schema.arktype.ts +9 -0
- package/assets/features/forms/files/schema.valibot.ts +9 -0
- package/assets/features/forms/files/schema.zod.ts +9 -0
- package/assets/features/http/feature.json +81 -0
- package/assets/features/http/files/client-test.ts +19 -0
- package/assets/features/http/files/client.ts +20 -0
- package/assets/features/i18n/feature.json +139 -0
- package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
- package/assets/features/i18n/files/Languages.tsx +12 -0
- package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
- package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
- package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
- package/assets/features/i18n/files/remote-ar.json.eta +3 -0
- package/assets/features/i18n/files/remote-config.ts +9 -0
- package/assets/features/i18n/files/remote-en.json.eta +3 -0
- package/assets/features/i18n/files/remote-load.ts +4 -0
- package/assets/features/i18n/files/shell-config.ts +20 -0
- package/assets/features/i18n/files/shell-init-test.ts +32 -0
- package/assets/features/i18n/files/shell-init.ts +7 -0
- package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
- package/assets/features/lint-oxlint/files/prettierignore +1 -0
- package/assets/features/route-factory/feature.json +60 -0
- package/assets/features/route-factory/files/contract.ts.eta +7 -0
- package/assets/features/route-factory/files/routes-contract.ts.eta +11 -0
- package/assets/features/route-factory/files/routes-test.ts.eta +9 -0
- package/assets/features/route-factory/files/routes.ts.eta +11 -0
- package/assets/features/storybook/feature.json +119 -0
- package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
- package/assets/features/storybook/files/Home.stories.tsx +9 -0
- package/assets/features/storybook/files/button.stories.tsx +14 -0
- package/assets/features/storybook/files/remote-main.ts +13 -0
- package/assets/features/storybook/files/rsbuild.config.ts +10 -0
- package/assets/features/storybook/files/shell-main.ts +39 -0
- package/assets/features/tailwind/feature.json +87 -0
- package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
- package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
- package/assets/features/tailwind/files/postcss.config.mjs +1 -0
- package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
- package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
- package/assets/features/testing/files/vitest.config.ts +11 -2
- package/assets/features/theming/feature.json +103 -0
- package/assets/features/theming/files/Theme.tsx +15 -0
- package/assets/features/theming/files/ThemeToggle.tsx +23 -0
- package/assets/features/theming/files/apply-stored-test.ts +37 -0
- package/assets/features/theming/files/apply-stored.ts +13 -0
- package/assets/features/theming/files/head-script.ts +8 -0
- package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
- package/assets/features/theming/files/theme.css +14 -0
- package/assets/features/theming/files/themes.ts +30 -0
- package/assets/features/theming/files/tokens-test.ts +9 -0
- package/assets/features/theming/files/tokens.ts +9 -0
- package/assets/features/ui-shadcn/feature.json +61 -0
- package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
- package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
- package/assets/features/ui-shadcn/files/components.json.eta +22 -0
- package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
- package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
- package/assets/features/url-factory/files/endpoints.ts +13 -2
- package/assets/templates/monorepo/feature.json +26 -0
- package/assets/templates/monorepo/files/root/AGENTS.md.eta +3 -3
- package/assets/templates/monorepo/files/root/README.md.eta +11 -11
- package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
- package/assets/templates/monorepo/files/shell/src/pages/Home.tsx.eta +3 -1
- package/dist/index.js +62 -0
- package/package.json +4 -4
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { announceReady } from './events';
|
|
3
|
+
|
|
4
|
+
/** Tells the other remotes that <%= it.app.name %> is on the page, each time it mounts. */
|
|
5
|
+
export function Announce({ children }: { children: ReactNode }) {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
announceReady();
|
|
8
|
+
}, []);
|
|
9
|
+
return <>{children}</>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every event the platform can carry, by versioned name: <domain>.<event>.v<major>, like
|
|
3
|
+
* cart.item-added.v1. Types only: nothing here runs, so any remote can read it.
|
|
4
|
+
*
|
|
5
|
+
* Add an event by adding a line. Change a payload in a way old listeners cannot read by adding
|
|
6
|
+
* a new version (cart.item-added.v2) and publishing both until every listener has moved.
|
|
7
|
+
*/
|
|
8
|
+
export interface Events {
|
|
9
|
+
/** A remote is on the page. The sample event: every remote announces itself, every other one can listen. */
|
|
10
|
+
'mfe.ready.v1': { mfe: string };
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# contracts
|
|
2
|
+
|
|
3
|
+
What the micro-frontends agree on, and nothing else. A remote never imports another remote; it
|
|
4
|
+
imports this.
|
|
5
|
+
|
|
6
|
+
- `src/events`: the events remotes publish and listen to, as types.
|
|
7
|
+
- `src/routes`: each remote's public paths, as plain data (added by `route-factory`).
|
|
8
|
+
|
|
9
|
+
Keep it to types and plain data with no imports, so reading it costs a remote nothing at run time
|
|
10
|
+
and it never pulls in another remote's code.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { publish, resetEventBusForTests } from '@mfeflow/event-bus';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { announceReady, onOtherRemoteReady } from './events';
|
|
4
|
+
|
|
5
|
+
afterEach(resetEventBusForTests);
|
|
6
|
+
|
|
7
|
+
describe('the <%= it.app.name %> events', () => {
|
|
8
|
+
it('hears another remote and not itself', () => {
|
|
9
|
+
const heard = vi.fn();
|
|
10
|
+
const stop = onOtherRemoteReady(heard);
|
|
11
|
+
announceReady();
|
|
12
|
+
publish('mfe.ready.v1', { mfe: 'somewhere-else' });
|
|
13
|
+
expect(heard).toHaveBeenCalledExactlyOnceWith('somewhere-else');
|
|
14
|
+
stop();
|
|
15
|
+
publish('mfe.ready.v1', { mfe: 'later' });
|
|
16
|
+
expect(heard).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { publish, subscribe } from '@mfeflow/event-bus';
|
|
2
|
+
import type { Events } from '<%= it.scope %>/contracts/events';
|
|
3
|
+
|
|
4
|
+
// The platform's event map is the contracts package's. This one declaration merges it in.
|
|
5
|
+
declare module '@mfeflow/event-bus' {
|
|
6
|
+
// oxlint-disable-next-line typescript/no-empty-interface -- merges the contracts into the platform map
|
|
7
|
+
interface EventMap extends Events {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Tells the other remotes that <%= it.app.name %> is on the page. */
|
|
11
|
+
export function announceReady(): void {
|
|
12
|
+
publish('mfe.ready.v1', { mfe: '<%= it.app.name %>' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Calls `handler` with the name of each other remote that announces itself. Returns the function that stops it. */
|
|
16
|
+
export function onOtherRemoteReady(handler: (mfe: string) => void): () => void {
|
|
17
|
+
return subscribe('mfe.ready.v1', ({ mfe }) => {
|
|
18
|
+
if (mfe !== '<%= it.app.name %>') handler(mfe);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { publish, resetEventBusForTests, subscribe } from '@mfeflow/event-bus';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { configureShellEvents } from './events';
|
|
4
|
+
|
|
5
|
+
afterEach(resetEventBusForTests);
|
|
6
|
+
|
|
7
|
+
describe('the shell event bus', () => {
|
|
8
|
+
it('carries the contracts events between remotes', () => {
|
|
9
|
+
const heard = vi.fn();
|
|
10
|
+
subscribe('mfe.ready.v1', heard);
|
|
11
|
+
publish('mfe.ready.v1', { mfe: 'cart' });
|
|
12
|
+
expect(heard).toHaveBeenCalledExactlyOnceWith({ mfe: 'cart' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('replays the latest announcement to a remote that mounts late', () => {
|
|
16
|
+
configureShellEvents();
|
|
17
|
+
publish('mfe.ready.v1', { mfe: 'cart' });
|
|
18
|
+
const late = vi.fn();
|
|
19
|
+
subscribe('mfe.ready.v1', late);
|
|
20
|
+
expect(late).toHaveBeenCalledExactlyOnceWith({ mfe: 'cart' });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { configureEvent } from '@mfeflow/event-bus';
|
|
2
|
+
import type { Events } from '<%= it.scope %>/contracts/events';
|
|
3
|
+
|
|
4
|
+
// The platform's event map is the contracts package's. This one declaration merges it in.
|
|
5
|
+
declare module '@mfeflow/event-bus' {
|
|
6
|
+
// oxlint-disable-next-line typescript/no-empty-interface -- merges the contracts into the platform map
|
|
7
|
+
interface EventMap extends Events {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How the platform's events behave, set once at boot. The shell imports the bus here, which makes
|
|
12
|
+
* it the one that provides the copy every remote shares.
|
|
13
|
+
*/
|
|
14
|
+
export function configureShellEvents(): void {
|
|
15
|
+
// "This remote is up" is a fact about the page, not a moment: a remote that mounts later still
|
|
16
|
+
// hears the latest announcement. Only the last one is kept. Leave replay off for events that
|
|
17
|
+
// are things that happened, like cart.item-added.v1.
|
|
18
|
+
configureEvent('mfe.ready.v1', { replay: true });
|
|
19
|
+
}
|
|
@@ -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 * 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>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "http",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A fetch client over your url-factory endpoints (@mfeflow/http): src/api/client.ts in every app, with retries for idempotent requests, timeouts and typed errors. The API base URL is the runtime config's apiUrl.",
|
|
5
|
+
"requires": ["core", "tsconfig", "url-factory"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/api/client.ts",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/client.ts"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/api/client.ts",
|
|
14
|
+
"target": "remotes",
|
|
15
|
+
"from": "files/client.ts"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"to": "src/api/client.test.ts",
|
|
19
|
+
"target": "shell",
|
|
20
|
+
"from": "files/client-test.ts",
|
|
21
|
+
"when": {
|
|
22
|
+
"feature:testing": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"to": "src/api/client.test.ts",
|
|
27
|
+
"target": "remotes",
|
|
28
|
+
"from": "files/client-test.ts",
|
|
29
|
+
"when": {
|
|
30
|
+
"feature:testing": true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"dependencies": [
|
|
35
|
+
{
|
|
36
|
+
"name": "@mfeflow/http",
|
|
37
|
+
"range": "latest",
|
|
38
|
+
"target": "shell"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "@mfeflow/http",
|
|
42
|
+
"range": "latest",
|
|
43
|
+
"target": "remotes"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"transforms": [
|
|
47
|
+
{
|
|
48
|
+
"op": "add-env-key",
|
|
49
|
+
"file": "src/config/schema.ts",
|
|
50
|
+
"target": "shell",
|
|
51
|
+
"key": "apiUrl",
|
|
52
|
+
"scope": "runtime",
|
|
53
|
+
"schema": {
|
|
54
|
+
"zod": "z.string().min(1)",
|
|
55
|
+
"valibot": "v.pipe(v.string(), v.minLength(1))",
|
|
56
|
+
"arktype": "'string > 0'"
|
|
57
|
+
},
|
|
58
|
+
"value": "/api",
|
|
59
|
+
"configFiles": [
|
|
60
|
+
"public/config/runtime-config.dev.json",
|
|
61
|
+
"public/config/runtime-config.staging.json",
|
|
62
|
+
"public/config/runtime-config.prod.json"
|
|
63
|
+
],
|
|
64
|
+
"when": {
|
|
65
|
+
"feature:env-t3": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"op": "add-env-key",
|
|
70
|
+
"file": "public/config/runtime-config.json",
|
|
71
|
+
"target": "shell",
|
|
72
|
+
"key": "apiUrl",
|
|
73
|
+
"scope": "runtime",
|
|
74
|
+
"schema": "z.string().min(1)",
|
|
75
|
+
"value": "/api",
|
|
76
|
+
"when": {
|
|
77
|
+
"feature:env-t3": false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isHttpError } from '@mfeflow/http';
|
|
2
|
+
import { http, HttpResponse } from 'msw';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { server } from '../mocks/server';
|
|
5
|
+
import { api } from './client';
|
|
6
|
+
|
|
7
|
+
describe('the api client', () => {
|
|
8
|
+
it('reads an endpoint through the mock server', async () => {
|
|
9
|
+
expect(await api.get('health')).toEqual({ ok: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('turns a failure into an HttpError with the status', async () => {
|
|
13
|
+
server.use(
|
|
14
|
+
http.get('*/api/health', () => HttpResponse.json({ error: 'down' }, { status: 404 })),
|
|
15
|
+
);
|
|
16
|
+
const error = await api.get('health').catch((e: unknown) => e);
|
|
17
|
+
expect(isHttpError(error) && error.status).toBe(404);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHttpClient } from '@mfeflow/http';
|
|
2
|
+
import { apiUrl, type endpoints } from './endpoints';
|
|
3
|
+
|
|
4
|
+
// What each endpoint answers with, by the name in ./endpoints. Add a line per endpoint you call.
|
|
5
|
+
interface Responses {
|
|
6
|
+
health: { ok: boolean };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One client per app over the endpoints in ./endpoints. The base URL is the runtime config's
|
|
11
|
+
* `apiUrl`. Idempotent requests retry on a network error, a timeout and a 502, 503 or 504; a
|
|
12
|
+
* failure is an HttpError with the status and body, and no URL in its message.
|
|
13
|
+
*
|
|
14
|
+
* const { ok } = await api.get('health');
|
|
15
|
+
*/
|
|
16
|
+
export const api = createHttpClient<typeof endpoints, Responses>({
|
|
17
|
+
url: apiUrl,
|
|
18
|
+
// Return the signed-in user's token; no header is sent while this returns undefined.
|
|
19
|
+
// getToken: () => session.accessToken(),
|
|
20
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Translations with @mfeflow/i18n: the shell starts one i18next instance and sets lang and dir; each remote loads its own messages from its own deploy, so a translation ships with the remote that uses it. Sample messages in English and Arabic (right to left) and a language switcher you own.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/i18n/config.ts",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/shell-config.ts"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/i18n/init.ts",
|
|
14
|
+
"target": "shell",
|
|
15
|
+
"from": "files/shell-init.ts"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"to": "src/i18n/Languages.tsx",
|
|
19
|
+
"target": "shell",
|
|
20
|
+
"from": "files/Languages.tsx"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"to": "src/i18n/LanguageSwitcher.tsx",
|
|
24
|
+
"target": "shell",
|
|
25
|
+
"from": "files/LanguageSwitcher.tsx"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"to": "src/i18n/init.test.ts",
|
|
29
|
+
"target": "shell",
|
|
30
|
+
"from": "files/shell-init-test.ts",
|
|
31
|
+
"when": {
|
|
32
|
+
"feature:testing": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"to": "src/i18n/LanguageSwitcher.test.tsx",
|
|
37
|
+
"target": "shell",
|
|
38
|
+
"from": "files/language-switcher-test.tsx",
|
|
39
|
+
"when": {
|
|
40
|
+
"feature:testing": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"to": "src/i18n/config.ts",
|
|
45
|
+
"target": "remotes",
|
|
46
|
+
"from": "files/remote-config.ts"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"to": "src/i18n/load.ts",
|
|
50
|
+
"target": "remotes",
|
|
51
|
+
"from": "files/remote-load.ts"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"to": "src/i18n/I18n.tsx",
|
|
55
|
+
"target": "remotes",
|
|
56
|
+
"from": "files/remote-I18n.tsx.eta",
|
|
57
|
+
"template": true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"to": "src/i18n/messages/en.json",
|
|
61
|
+
"target": "remotes",
|
|
62
|
+
"from": "files/remote-en.json.eta",
|
|
63
|
+
"template": true
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"to": "src/i18n/messages/ar.json",
|
|
67
|
+
"target": "remotes",
|
|
68
|
+
"from": "files/remote-ar.json.eta",
|
|
69
|
+
"template": true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"to": "src/i18n/I18n.test.tsx",
|
|
73
|
+
"target": "remotes",
|
|
74
|
+
"from": "files/remote-I18n-test.tsx.eta",
|
|
75
|
+
"template": true,
|
|
76
|
+
"when": {
|
|
77
|
+
"feature:testing": true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"dependencies": [
|
|
82
|
+
{
|
|
83
|
+
"name": "@mfeflow/i18n",
|
|
84
|
+
"range": "latest",
|
|
85
|
+
"target": "shell"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "i18next",
|
|
89
|
+
"range": "26.5.12",
|
|
90
|
+
"target": "shell"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "react-i18next",
|
|
94
|
+
"range": "17.1.9",
|
|
95
|
+
"target": "shell"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "@mfeflow/i18n",
|
|
99
|
+
"range": "latest",
|
|
100
|
+
"target": "remotes"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "i18next",
|
|
104
|
+
"range": "26.5.12",
|
|
105
|
+
"target": "remotes"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "react-i18next",
|
|
109
|
+
"range": "17.1.9",
|
|
110
|
+
"target": "remotes"
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"transforms": [
|
|
114
|
+
{
|
|
115
|
+
"op": "add-boot-step",
|
|
116
|
+
"file": "src/bootstrap.tsx",
|
|
117
|
+
"target": "shell",
|
|
118
|
+
"call": "initShellI18n",
|
|
119
|
+
"from": "./i18n/init",
|
|
120
|
+
"await": true
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"op": "wrap-provider",
|
|
124
|
+
"file": "src/App.tsx",
|
|
125
|
+
"target": "shell",
|
|
126
|
+
"component": "Languages",
|
|
127
|
+
"from": "./i18n/Languages",
|
|
128
|
+
"function": "App"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"op": "wrap-provider",
|
|
132
|
+
"file": "src/exposed/App.tsx",
|
|
133
|
+
"target": "remotes",
|
|
134
|
+
"component": "I18n",
|
|
135
|
+
"from": "../i18n/I18n",
|
|
136
|
+
"function": "App"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useLanguage, useMfeTranslation } from '@mfeflow/i18n/react';
|
|
2
|
+
import { loadShellMessages } from './config';
|
|
3
|
+
|
|
4
|
+
/** Your component: move it into the header or restyle it. Changing the language also sets <html lang dir>. */
|
|
5
|
+
export function LanguageSwitcher() {
|
|
6
|
+
const { t } = useMfeTranslation('shell', loadShellMessages);
|
|
7
|
+
const { language, languages, setLanguage } = useLanguage();
|
|
8
|
+
return (
|
|
9
|
+
<label
|
|
10
|
+
className="language-switcher"
|
|
11
|
+
style={{ position: 'fixed', left: '1rem', bottom: '1rem' }}
|
|
12
|
+
>
|
|
13
|
+
{t('language')}{' '}
|
|
14
|
+
<select
|
|
15
|
+
value={language}
|
|
16
|
+
onChange={(event) => {
|
|
17
|
+
void setLanguage(event.target.value);
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
{languages.map((option) => (
|
|
21
|
+
<option key={option.code} value={option.code}>
|
|
22
|
+
{option.label}
|
|
23
|
+
</option>
|
|
24
|
+
))}
|
|
25
|
+
</select>
|
|
26
|
+
</label>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
3
|
+
|
|
4
|
+
/** Wraps the shell and shows the language switcher. The instance itself starts in the boot step. */
|
|
5
|
+
export function Languages({ children }: { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
{children}
|
|
9
|
+
<LanguageSwitcher />
|
|
10
|
+
</>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { resetPlatform, renderWithPlatform } from '@mfeflow/testing';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { initShellI18n } from './init';
|
|
5
|
+
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
6
|
+
|
|
7
|
+
afterEach(resetPlatform);
|
|
8
|
+
|
|
9
|
+
describe('LanguageSwitcher', () => {
|
|
10
|
+
it('switches the language and the page direction', async () => {
|
|
11
|
+
await initShellI18n();
|
|
12
|
+
renderWithPlatform(<LanguageSwitcher />);
|
|
13
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ar' } });
|
|
14
|
+
await waitFor(() => {
|
|
15
|
+
expect(document.documentElement.dir).toBe('rtl');
|
|
16
|
+
});
|
|
17
|
+
expect(screen.getByText('اللغة')).toBeDefined();
|
|
18
|
+
});
|
|
19
|
+
});
|