create-flowdular 0.1.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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/bin.js +526 -0
- package/package.json +35 -0
- package/template/default/.prettierrc.json +6 -0
- package/template/default/README.md +56 -0
- package/template/default/_gitignore +8 -0
- package/template/default/flowdular.json +14 -0
- package/template/default/modules/example/migrations/0001_example_core.down.sql +1 -0
- package/template/default/modules/example/migrations/0001_example_core.up.sql +14 -0
- package/template/default/modules/example/module.json +22 -0
- package/template/default/modules/example/package.json +27 -0
- package/template/default/modules/example/spec/module.yaml +43 -0
- package/template/default/modules/example/src/acl/permissions.ts +6 -0
- package/template/default/modules/example/src/api/endpoints.ts +81 -0
- package/template/default/modules/example/src/client/NotesView.tsrx +236 -0
- package/template/default/modules/example/src/client/api.ts +38 -0
- package/template/default/modules/example/src/client/contribution.tsrx +52 -0
- package/template/default/modules/example/src/client/index.ts +18 -0
- package/template/default/modules/example/src/client/navigation-copy.ts +9 -0
- package/template/default/modules/example/src/client/state.ts +14 -0
- package/template/default/modules/example/src/domain/types.ts +12 -0
- package/template/default/modules/example/src/index.ts +24 -0
- package/template/default/modules/example/src/platform.ts +23 -0
- package/template/default/modules/example/src/server/index.ts +3 -0
- package/template/default/modules/example/src/server/runtime.ts +84 -0
- package/template/default/modules/example/src/services/database-repository.ts +82 -0
- package/template/default/modules/example/src/services/index.ts +6 -0
- package/template/default/modules/example/src/services/migration.ts +35 -0
- package/template/default/modules/example/src/services/note-service.ts +54 -0
- package/template/default/modules/example/src/services/repository.ts +7 -0
- package/template/default/modules/example/tests/module.test.ts +67 -0
- package/template/default/modules/example/tests/notes.test.ts +83 -0
- package/template/default/modules/example/tests/support/database.ts +69 -0
- package/template/default/modules/example/translations/en.json +34 -0
- package/template/default/modules/example/translations/pl.json +34 -0
- package/template/default/modules/example/tsconfig.json +14 -0
- package/template/default/modules/example/vitest.config.ts +10 -0
- package/template/default/package.json +26 -0
- package/template/default/platform/index.html +13 -0
- package/template/default/platform/octane.config.ts +117 -0
- package/template/default/platform/package.json +27 -0
- package/template/default/platform/public/favicon.svg +1 -0
- package/template/default/platform/scripts/dev.mjs +18 -0
- package/template/default/platform/src/App.tsrx +80 -0
- package/template/default/platform/src/generated/modules.client.ts +15 -0
- package/template/default/platform/src/generated/modules.server.ts +31 -0
- package/template/default/platform/src/server/database.ts +25 -0
- package/template/default/platform/src/styles.css +39 -0
- package/template/default/platform/tsconfig.json +15 -0
- package/template/default/platform/vite.config.ts +16 -0
- package/template/default/pnpm-workspace.yaml +6 -0
- package/template/default/tsconfig.base.json +18 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@flowdular/contracts/schemas/project.schema.json",
|
|
3
|
+
"schemaVersion": 1,
|
|
4
|
+
"architectureVersion": "0.2.0",
|
|
5
|
+
"specs": {
|
|
6
|
+
"platformRoot": "specs",
|
|
7
|
+
"moduleDirectory": "spec"
|
|
8
|
+
},
|
|
9
|
+
"modules": {
|
|
10
|
+
"roots": ["modules"],
|
|
11
|
+
"enabled": ["system.core", "auth.core", "example.core"]
|
|
12
|
+
},
|
|
13
|
+
"locales": ["en", "pl"]
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS example_notes;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS example_notes (
|
|
2
|
+
tenant_id TEXT NOT NULL,
|
|
3
|
+
id TEXT NOT NULL,
|
|
4
|
+
title TEXT NOT NULL,
|
|
5
|
+
body TEXT NOT NULL,
|
|
6
|
+
created_at BIGINT NOT NULL,
|
|
7
|
+
PRIMARY KEY (tenant_id, id)
|
|
8
|
+
);
|
|
9
|
+
CREATE INDEX IF NOT EXISTS example_notes_tenant_created_idx ON example_notes (tenant_id, created_at DESC);
|
|
10
|
+
ALTER TABLE example_notes ENABLE ROW LEVEL SECURITY;
|
|
11
|
+
ALTER TABLE example_notes FORCE ROW LEVEL SECURITY;
|
|
12
|
+
CREATE POLICY example_notes_tenant_policy ON example_notes
|
|
13
|
+
USING (tenant_id = current_setting('coreloom.tenant_id', true))
|
|
14
|
+
WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/@flowdular/contracts/schemas/module.schema.json",
|
|
3
|
+
"schemaVersion": 1,
|
|
4
|
+
"id": "example.core",
|
|
5
|
+
"package": "@app/module-example",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"profile": "full",
|
|
8
|
+
"capabilities": ["api", "database", "client", "translations"],
|
|
9
|
+
"platform": {
|
|
10
|
+
"server": true,
|
|
11
|
+
"client": true
|
|
12
|
+
},
|
|
13
|
+
"dependencies": [
|
|
14
|
+
{
|
|
15
|
+
"id": "auth.core",
|
|
16
|
+
"range": "0.10.0"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"tenancy": "required",
|
|
20
|
+
"locales": ["en", "pl"],
|
|
21
|
+
"stability": "experimental"
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/module-example",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./client": "./src/client/index.ts",
|
|
9
|
+
"./server": "./src/server/index.ts",
|
|
10
|
+
"./platform": "./src/platform.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsrx-tsc --noEmit -p tsconfig.json",
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"octane": "0.1.51",
|
|
18
|
+
"segment-state": "0.2.0",
|
|
19
|
+
"@flowdular/sdk": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tsrx/typescript-plugin": "0.3.120",
|
|
23
|
+
"@types/node": "24.13.3",
|
|
24
|
+
"typescript": "5.9.3",
|
|
25
|
+
"vitest": "4.1.10"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
schemaVersion: 1
|
|
2
|
+
id: example.core
|
|
3
|
+
specVersion: 0.1.0
|
|
4
|
+
status: approved
|
|
5
|
+
name: Example Notes
|
|
6
|
+
description: A minimal tenant-scoped record module to copy when you build your first real module.
|
|
7
|
+
profile: full
|
|
8
|
+
capabilities:
|
|
9
|
+
- api
|
|
10
|
+
- database
|
|
11
|
+
- client
|
|
12
|
+
- translations
|
|
13
|
+
dependencies:
|
|
14
|
+
- id: auth.core
|
|
15
|
+
range: 0.10.0
|
|
16
|
+
tenancy: required
|
|
17
|
+
locales:
|
|
18
|
+
- en
|
|
19
|
+
- pl
|
|
20
|
+
invariants:
|
|
21
|
+
- Persistence runs on the shared asynchronous database contract. The module receives a leased handle from platform composition, never a driver or a connection string, and provides explicit SQL for every dialect it declares.
|
|
22
|
+
- The PostgreSQL notes table enables and forces row-level security with USING and WITH CHECK policies bound to transaction-local coreloom.tenant_id, and the runtime role holds neither SUPERUSER nor BYPASSRLS. Migrations use a separate lease.
|
|
23
|
+
- Every note is owned by exactly one tenant and every query uses the trusted tenant identifier.
|
|
24
|
+
permissions:
|
|
25
|
+
- id: example.notes.read
|
|
26
|
+
description: Read notes in the active tenant.
|
|
27
|
+
- id: example.notes.manage
|
|
28
|
+
description: Create notes in the active tenant.
|
|
29
|
+
dataOwnership:
|
|
30
|
+
- example.core owns note identity, title, body, and creation time.
|
|
31
|
+
acceptanceScenarios:
|
|
32
|
+
- id: EXAMPLE-LIST
|
|
33
|
+
given: Notes exist in more than one tenant.
|
|
34
|
+
when: An authorized principal lists notes.
|
|
35
|
+
then: Only notes owned by the active tenant are returned, newest first.
|
|
36
|
+
- id: EXAMPLE-CREATE
|
|
37
|
+
given: An authorized principal supplies a title and a body.
|
|
38
|
+
when: The note is created.
|
|
39
|
+
then: A tenant-owned note is persisted and returned with its creation time.
|
|
40
|
+
- id: EXAMPLE-DENY
|
|
41
|
+
given: A principal is unauthenticated or lacks the required example permission.
|
|
42
|
+
when: The principal reads or creates a note.
|
|
43
|
+
then: The server denies the operation before repository access.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEndpoint,
|
|
3
|
+
jsonResponse,
|
|
4
|
+
problemResponse,
|
|
5
|
+
readJsonObject,
|
|
6
|
+
requiredString,
|
|
7
|
+
} from '@flowdular/sdk/server';
|
|
8
|
+
import type { AuthRuntime } from '@flowdular/sdk/modules/auth/server';
|
|
9
|
+
import {
|
|
10
|
+
endpointIdentityFromContext,
|
|
11
|
+
principalFromContext,
|
|
12
|
+
sessionMutationDenial,
|
|
13
|
+
} from '@flowdular/sdk/modules/auth/server';
|
|
14
|
+
import { EXAMPLE_PERMISSIONS } from '../acl/permissions.ts';
|
|
15
|
+
import { NoteServiceError } from '../services/note-service.ts';
|
|
16
|
+
import type { ExampleRuntime } from '../server/runtime.ts';
|
|
17
|
+
|
|
18
|
+
function failure(error: unknown): Response {
|
|
19
|
+
if (error instanceof NoteServiceError) {
|
|
20
|
+
return jsonResponse(
|
|
21
|
+
{ error: { code: error.code, message: error.message } },
|
|
22
|
+
error.status,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return problemResponse(error, 'The example operation failed.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createExampleRoutes(
|
|
29
|
+
auth: AuthRuntime,
|
|
30
|
+
runtime: ExampleRuntime,
|
|
31
|
+
) {
|
|
32
|
+
const list = defineEndpoint({
|
|
33
|
+
id: 'example.notes.list',
|
|
34
|
+
path: '/api/example/notes',
|
|
35
|
+
methods: ['GET'],
|
|
36
|
+
access: { kind: 'permission', permission: EXAMPLE_PERMISSIONS.read },
|
|
37
|
+
resolveIdentity: endpointIdentityFromContext,
|
|
38
|
+
handler: async ({ octane }) => {
|
|
39
|
+
try {
|
|
40
|
+
const principal = principalFromContext(octane)!;
|
|
41
|
+
const service = await runtime.service();
|
|
42
|
+
return jsonResponse({ notes: await service.list(principal.tenantId) });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return failure(error);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const create = defineEndpoint({
|
|
49
|
+
id: 'example.notes.create',
|
|
50
|
+
path: '/api/example/notes',
|
|
51
|
+
methods: ['POST'],
|
|
52
|
+
access: { kind: 'permission', permission: EXAMPLE_PERMISSIONS.manage },
|
|
53
|
+
resolveIdentity: endpointIdentityFromContext,
|
|
54
|
+
handler: async ({ octane }) => {
|
|
55
|
+
const denial = sessionMutationDenial(octane, auth);
|
|
56
|
+
if (denial) return denial;
|
|
57
|
+
try {
|
|
58
|
+
const value = await readJsonObject(octane.request);
|
|
59
|
+
const principal = principalFromContext(octane)!;
|
|
60
|
+
const service = await runtime.service();
|
|
61
|
+
return jsonResponse(
|
|
62
|
+
{
|
|
63
|
+
note: await service.create(principal.tenantId, {
|
|
64
|
+
title: requiredString(value, 'title', { min: 1, max: 120 }),
|
|
65
|
+
body: requiredString(value, 'body', { min: 1, max: 4_000 }),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
201,
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return failure(error);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
return [list.serverRoute, create.serverRoute] as const;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const endpoints = [
|
|
79
|
+
'example.notes.list',
|
|
80
|
+
'example.notes.create',
|
|
81
|
+
] as const;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'octane';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Button,
|
|
5
|
+
Drawer,
|
|
6
|
+
FormField,
|
|
7
|
+
Icon,
|
|
8
|
+
PageHeader,
|
|
9
|
+
SearchField,
|
|
10
|
+
TableCard,
|
|
11
|
+
type TableColumn,
|
|
12
|
+
} from '@flowdular/sdk/ui';
|
|
13
|
+
import { activeLocale, t } from '@flowdular/sdk/client';
|
|
14
|
+
import { useValue } from 'segment-state';
|
|
15
|
+
import type { Note } from '../domain/types.ts';
|
|
16
|
+
import { createNote, loadNotes } from './api.ts';
|
|
17
|
+
import { createExampleClientState } from './state.ts';
|
|
18
|
+
|
|
19
|
+
export interface NotesViewProps {
|
|
20
|
+
readonly csrfToken: string;
|
|
21
|
+
readonly canManage: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formValue(form: FormData, key: string): string {
|
|
25
|
+
const value = form.get(key);
|
|
26
|
+
return typeof value === 'string' ? value : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function columns(): readonly TableColumn<Note>[] {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
key: 'note',
|
|
33
|
+
header: t('example.table.column.note'),
|
|
34
|
+
width: '70%',
|
|
35
|
+
cell: (note) => <span class="ui-cell">
|
|
36
|
+
<b>{note.title}</b>
|
|
37
|
+
<span class="ui-cell__muted">{note.body}</span>
|
|
38
|
+
</span>,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'created',
|
|
42
|
+
header: t('example.table.column.created'),
|
|
43
|
+
width: '30%',
|
|
44
|
+
cell: (note) => new Intl.DateTimeFormat(activeLocale(), {
|
|
45
|
+
dateStyle: 'medium',
|
|
46
|
+
timeStyle: 'short',
|
|
47
|
+
}).format(new Date(note.createdAt)),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function NotesView(props: NotesViewProps) @{
|
|
53
|
+
const exampleState = useMemo(() => createExampleClientState(), []);
|
|
54
|
+
const [notes] = useValue(exampleState.state.notes);
|
|
55
|
+
const [status, setStatus] = useValue(exampleState.state.status);
|
|
56
|
+
const [error, setError] = useValue(exampleState.state.error);
|
|
57
|
+
const [query, setQuery] = useValue(exampleState.state.query);
|
|
58
|
+
const [formOpen, setFormOpen] = useValue(exampleState.state.formOpen);
|
|
59
|
+
const [formSession, setFormSession] = useValue(
|
|
60
|
+
exampleState.state.formSession,
|
|
61
|
+
);
|
|
62
|
+
const locale = activeLocale();
|
|
63
|
+
const term = query.trim().toLocaleLowerCase(locale);
|
|
64
|
+
const visible = notes.filter(
|
|
65
|
+
(note) =>
|
|
66
|
+
term === '' || note.title.toLocaleLowerCase(locale).includes(term),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const refresh = async () => {
|
|
70
|
+
setStatus('loading');
|
|
71
|
+
setError('');
|
|
72
|
+
try {
|
|
73
|
+
const records = await loadNotes();
|
|
74
|
+
exampleState.store.act(
|
|
75
|
+
(transaction) => transaction.set(exampleState.state.notes, records),
|
|
76
|
+
'example/loaded',
|
|
77
|
+
);
|
|
78
|
+
} catch (loadError) {
|
|
79
|
+
setError(
|
|
80
|
+
loadError instanceof Error
|
|
81
|
+
? loadError.message
|
|
82
|
+
: t('example.error.load'),
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
setStatus('idle');
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
void refresh();
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const openForm = () => {
|
|
94
|
+
setError('');
|
|
95
|
+
setFormSession((current) => current + 1);
|
|
96
|
+
setFormOpen(true);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const closeForm = () => {
|
|
100
|
+
setFormOpen(false);
|
|
101
|
+
setError('');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const submit = async (event: SubmitEvent) => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
const form = new FormData(event.currentTarget as HTMLFormElement);
|
|
107
|
+
setStatus('submitting');
|
|
108
|
+
setError('');
|
|
109
|
+
try {
|
|
110
|
+
const note = await createNote(
|
|
111
|
+
{ title: formValue(form, 'title'), body: formValue(form, 'body') },
|
|
112
|
+
props.csrfToken,
|
|
113
|
+
);
|
|
114
|
+
exampleState.store.act(
|
|
115
|
+
(transaction) => transaction.set(exampleState.state.notes, [
|
|
116
|
+
note,
|
|
117
|
+
...notes,
|
|
118
|
+
]),
|
|
119
|
+
'example/created',
|
|
120
|
+
);
|
|
121
|
+
closeForm();
|
|
122
|
+
} catch (saveError) {
|
|
123
|
+
setError(
|
|
124
|
+
saveError instanceof Error
|
|
125
|
+
? saveError.message
|
|
126
|
+
: t('example.error.create'),
|
|
127
|
+
);
|
|
128
|
+
} finally {
|
|
129
|
+
setStatus('idle');
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
<div class="ui-view">
|
|
134
|
+
<PageHeader
|
|
135
|
+
eyebrow={t('example.page.eyebrow')}
|
|
136
|
+
title={t('example.page.title')}
|
|
137
|
+
description={t('example.page.description')}
|
|
138
|
+
>
|
|
139
|
+
<Button size="sm" onClick={() => void refresh()}>
|
|
140
|
+
<Icon name="refresh" size={14} />
|
|
141
|
+
{t('example.page.action.refresh')}
|
|
142
|
+
</Button>
|
|
143
|
+
@if (props.canManage) {
|
|
144
|
+
<Button size="sm" variant="primary" onClick={openForm}>
|
|
145
|
+
<Icon name="plus" size={14} />
|
|
146
|
+
{t('example.page.action.new')}
|
|
147
|
+
</Button>
|
|
148
|
+
}
|
|
149
|
+
</PageHeader>
|
|
150
|
+
@if (error && !formOpen) {
|
|
151
|
+
<Alert>{error}</Alert>
|
|
152
|
+
}
|
|
153
|
+
<TableCard
|
|
154
|
+
title={t('example.table.title')}
|
|
155
|
+
count={t(
|
|
156
|
+
notes.length === 1
|
|
157
|
+
? 'example.table.count.one'
|
|
158
|
+
: 'example.table.count.other',
|
|
159
|
+
{ count: notes.length },
|
|
160
|
+
)}
|
|
161
|
+
search={<SearchField
|
|
162
|
+
value={query}
|
|
163
|
+
placeholder={t('example.table.search.placeholder')}
|
|
164
|
+
label={t('example.table.search.label')}
|
|
165
|
+
onInput={setQuery}
|
|
166
|
+
/>}
|
|
167
|
+
caption={t('example.table.caption')}
|
|
168
|
+
columns={columns()}
|
|
169
|
+
rows={visible}
|
|
170
|
+
rowKey={(note) => note.id}
|
|
171
|
+
status={status === 'loading' && notes.length === 0 ? 'loading' : 'idle'}
|
|
172
|
+
loadingLabel={t('example.table.loading')}
|
|
173
|
+
filtered={term !== ''}
|
|
174
|
+
empty={{
|
|
175
|
+
icon: 'file-text',
|
|
176
|
+
title: t('example.table.empty.title'),
|
|
177
|
+
hint: t('example.table.empty.hint'),
|
|
178
|
+
}}
|
|
179
|
+
emptyFiltered={{
|
|
180
|
+
icon: 'file-text',
|
|
181
|
+
title: t('example.table.emptyFiltered.title'),
|
|
182
|
+
hint: t('example.table.emptyFiltered.hint'),
|
|
183
|
+
}}
|
|
184
|
+
note={t('example.table.note')}
|
|
185
|
+
/>
|
|
186
|
+
<Drawer
|
|
187
|
+
open={formOpen}
|
|
188
|
+
title={t('example.drawer.title')}
|
|
189
|
+
subtitle={t('example.drawer.subtitle')}
|
|
190
|
+
onClose={closeForm}
|
|
191
|
+
>
|
|
192
|
+
<form
|
|
193
|
+
class="ui-drawer__form"
|
|
194
|
+
key={'example-form-' + formSession}
|
|
195
|
+
onSubmit={submit}
|
|
196
|
+
>
|
|
197
|
+
<div class="ui-drawer__body">
|
|
198
|
+
@if (error) {
|
|
199
|
+
<Alert>{error}</Alert>
|
|
200
|
+
}
|
|
201
|
+
<FormField label={t('example.field.title')}>
|
|
202
|
+
<input
|
|
203
|
+
class="ui-input"
|
|
204
|
+
id="title"
|
|
205
|
+
name="title"
|
|
206
|
+
required
|
|
207
|
+
maxLength={120}
|
|
208
|
+
/>
|
|
209
|
+
</FormField>
|
|
210
|
+
<FormField
|
|
211
|
+
label={t('example.field.body')}
|
|
212
|
+
help={t('example.field.body.help')}
|
|
213
|
+
>
|
|
214
|
+
<textarea
|
|
215
|
+
class="ui-textarea"
|
|
216
|
+
id="body"
|
|
217
|
+
required
|
|
218
|
+
rows={6}
|
|
219
|
+
maxLength={4000}
|
|
220
|
+
></textarea>
|
|
221
|
+
</FormField>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="ui-drawer__foot">
|
|
224
|
+
<Button type="button" onClick={closeForm}>{t(
|
|
225
|
+
'example.drawer.cancel',
|
|
226
|
+
)}</Button>
|
|
227
|
+
<Button
|
|
228
|
+
type="submit"
|
|
229
|
+
variant="primary"
|
|
230
|
+
disabled={status === 'submitting'}
|
|
231
|
+
>{t('example.drawer.save')}</Button>
|
|
232
|
+
</div>
|
|
233
|
+
</form>
|
|
234
|
+
</Drawer>
|
|
235
|
+
</div>
|
|
236
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { t } from '@flowdular/sdk/client/i18n';
|
|
2
|
+
import type { CreateNoteInput, Note } from '../domain/types.ts';
|
|
3
|
+
|
|
4
|
+
interface ErrorEnvelope {
|
|
5
|
+
readonly error?: { readonly message?: string };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function payload<T>(response: Response): Promise<T> {
|
|
9
|
+
const value = (await response.json()) as T & ErrorEnvelope;
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new Error(value.error?.message ?? t('example.error.request'));
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadNotes(): Promise<readonly Note[]> {
|
|
17
|
+
const response = await fetch('/api/example/notes', {
|
|
18
|
+
headers: { accept: 'application/json' },
|
|
19
|
+
credentials: 'same-origin',
|
|
20
|
+
});
|
|
21
|
+
return (await payload<{ readonly notes: readonly Note[] }>(response)).notes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function createNote(
|
|
25
|
+
input: CreateNoteInput,
|
|
26
|
+
csrfToken: string,
|
|
27
|
+
): Promise<Note> {
|
|
28
|
+
const response = await fetch('/api/example/notes', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'content-type': 'application/json',
|
|
32
|
+
'x-csrf-token': csrfToken,
|
|
33
|
+
},
|
|
34
|
+
credentials: 'same-origin',
|
|
35
|
+
body: JSON.stringify(input),
|
|
36
|
+
});
|
|
37
|
+
return (await payload<{ readonly note: Note }>(response)).note;
|
|
38
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ModuleClientContribution } from '@flowdular/sdk/client';
|
|
2
|
+
import { EXAMPLE_PERMISSIONS } from '../acl/permissions.ts';
|
|
3
|
+
import translationsEn from '../../translations/en.json';
|
|
4
|
+
import translationsPl from '../../translations/pl.json';
|
|
5
|
+
import { NotesView } from './NotesView.tsrx';
|
|
6
|
+
import {
|
|
7
|
+
exampleNavigationDescription,
|
|
8
|
+
exampleNavigationLabel,
|
|
9
|
+
} from './navigation-copy.ts';
|
|
10
|
+
|
|
11
|
+
export interface ExampleClientContributionOptions {
|
|
12
|
+
readonly csrfToken: string;
|
|
13
|
+
readonly scopes: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createExampleClientContribution(
|
|
17
|
+
options: ExampleClientContributionOptions,
|
|
18
|
+
): ModuleClientContribution {
|
|
19
|
+
return {
|
|
20
|
+
moduleId: 'example.core',
|
|
21
|
+
translations: { en: translationsEn, pl: translationsPl },
|
|
22
|
+
navigation: [
|
|
23
|
+
{
|
|
24
|
+
id: 'example.navigation',
|
|
25
|
+
viewId: 'example',
|
|
26
|
+
group: 'Operations',
|
|
27
|
+
/* The shell registers this contribution's bundles after the factory
|
|
28
|
+
returns. Getters resolve copy only when the shell reads navigation,
|
|
29
|
+
after registration, and again when the active locale changes. */
|
|
30
|
+
get label() {
|
|
31
|
+
return exampleNavigationLabel();
|
|
32
|
+
},
|
|
33
|
+
glyph: 'file-text',
|
|
34
|
+
get description() {
|
|
35
|
+
return exampleNavigationDescription();
|
|
36
|
+
},
|
|
37
|
+
scope: EXAMPLE_PERMISSIONS.read,
|
|
38
|
+
order: 20,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
views: [
|
|
42
|
+
{
|
|
43
|
+
id: 'example',
|
|
44
|
+
render: () =>
|
|
45
|
+
<NotesView
|
|
46
|
+
csrfToken={options.csrfToken}
|
|
47
|
+
canManage={options.scopes.includes(EXAMPLE_PERMISSIONS.manage)}
|
|
48
|
+
/>,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { createExampleClientContribution } from './contribution.tsrx';
|
|
2
|
+
export type { ExampleClientContributionOptions } from './contribution.tsrx';
|
|
3
|
+
export { NotesView } from './NotesView.tsrx';
|
|
4
|
+
import type {
|
|
5
|
+
ModuleClientContext,
|
|
6
|
+
ModuleClientContribution,
|
|
7
|
+
} from '@flowdular/sdk/client';
|
|
8
|
+
import { createExampleClientContribution as canonicalContribution } from './contribution.tsrx';
|
|
9
|
+
|
|
10
|
+
/* Canonical entry used by the generated platform composition. */
|
|
11
|
+
export function createClientContribution(
|
|
12
|
+
context: ModuleClientContext,
|
|
13
|
+
): ModuleClientContribution {
|
|
14
|
+
return canonicalContribution({
|
|
15
|
+
csrfToken: context.csrfToken,
|
|
16
|
+
scopes: context.scopes,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cell, createStore } from 'segment-state';
|
|
2
|
+
import type { Note } from '../domain/types.ts';
|
|
3
|
+
|
|
4
|
+
export function createExampleClientState() {
|
|
5
|
+
const store = createStore({
|
|
6
|
+
notes: cell<readonly Note[]>([]),
|
|
7
|
+
query: '',
|
|
8
|
+
formOpen: false,
|
|
9
|
+
status: cell<'idle' | 'loading' | 'submitting'>('idle'),
|
|
10
|
+
error: '',
|
|
11
|
+
formSession: 0,
|
|
12
|
+
});
|
|
13
|
+
return { store, state: store.state };
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Note {
|
|
2
|
+
readonly tenantId: string;
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly body: string;
|
|
6
|
+
readonly createdAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CreateNoteInput {
|
|
10
|
+
readonly title: string;
|
|
11
|
+
readonly body: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModuleManifest,
|
|
3
|
+
RegisteredModule,
|
|
4
|
+
} from '@flowdular/sdk/contracts';
|
|
5
|
+
import manifest from '../module.json' with { type: 'json' };
|
|
6
|
+
import { EXAMPLE_PERMISSIONS } from './acl/permissions.ts';
|
|
7
|
+
|
|
8
|
+
export const moduleDefinition = {
|
|
9
|
+
manifest: manifest as ModuleManifest,
|
|
10
|
+
navigation: [
|
|
11
|
+
{
|
|
12
|
+
id: 'example.navigation',
|
|
13
|
+
label: 'Notes',
|
|
14
|
+
href: '/example',
|
|
15
|
+
order: 20,
|
|
16
|
+
permission: EXAMPLE_PERMISSIONS.read,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
permissions: Object.values(EXAMPLE_PERMISSIONS),
|
|
20
|
+
} satisfies RegisteredModule;
|
|
21
|
+
|
|
22
|
+
export { EXAMPLE_PERMISSIONS } from './acl/permissions.ts';
|
|
23
|
+
export { NoteService, NoteServiceError } from './services/note-service.ts';
|
|
24
|
+
export type { CreateNoteInput, Note } from './domain/types.ts';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PlatformServerComposition,
|
|
3
|
+
PlatformServerContext,
|
|
4
|
+
} from '@flowdular/sdk/modules/auth/server';
|
|
5
|
+
import { createExampleRoutes, createExampleRuntime } from './server/index.ts';
|
|
6
|
+
|
|
7
|
+
export function createServerComposition(
|
|
8
|
+
context: PlatformServerContext,
|
|
9
|
+
): PlatformServerComposition {
|
|
10
|
+
const runtime = createExampleRuntime({
|
|
11
|
+
databases: context.databases,
|
|
12
|
+
purpose:
|
|
13
|
+
context.environment.NODE_ENV === 'test'
|
|
14
|
+
? 'test'
|
|
15
|
+
: context.environment.NODE_ENV === 'production'
|
|
16
|
+
? 'runtime'
|
|
17
|
+
: 'preview',
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
routes: createExampleRoutes(context.auth, runtime),
|
|
21
|
+
dispose: () => runtime.dispose(),
|
|
22
|
+
};
|
|
23
|
+
}
|