@aircloudhq/web 1.0.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/src/model.js ADDED
@@ -0,0 +1,38 @@
1
+ // ActiveModel::Naming and ActiveModel::Translation for a projection resource (spec S4 § @aircloudhq/web
2
+ // public modules): the `activemodel.*` key of the current locale (and its declared fallbacks) when it
3
+ // exists, else the projection's `display_name` — the default-locale name, whose one carrier is
4
+ // db/schema/. No inflector is involved (ADR-213 D2).
5
+ import { i18nFor } from './i18n/current.js';
6
+
7
+ function fieldOf(resource, name) {
8
+ const f = resource.fields.find((x) => x.name === name);
9
+ if (!f) throw new Error(`resource "${resource.entity}" has no field "${name}"`);
10
+ return f;
11
+ }
12
+
13
+ /** Model.model_name.human — `modelName(resource).human({count})`. */
14
+ export function modelName(resource, options = {}) {
15
+ const i18n = i18nFor(options);
16
+ return {
17
+ singular: resource.entity,
18
+ param_key: resource.entity,
19
+ route_key: resource.route_prefix,
20
+ human({ count } = {}) {
21
+ const v = i18n?.lookupDeclared(`activemodel.models.${resource.entity}`);
22
+ if (typeof v === 'string') return v;
23
+ if (v && typeof v === 'object') {
24
+ const one = count === undefined || count === 1 || /^1(\.0+)?$/.test(String(count));
25
+ const pick = one ? v.one ?? v.other : v.other ?? v.one;
26
+ if (typeof pick === 'string') return pick;
27
+ }
28
+ return resource.display_name;
29
+ },
30
+ };
31
+ }
32
+
33
+ /** Model.human_attribute_name(:field) */
34
+ export function humanAttributeName(resource, field, options = {}) {
35
+ const f = fieldOf(resource, field);
36
+ const v = i18nFor(options)?.lookupDeclared(`activemodel.attributes.${resource.entity}.${field}`);
37
+ return typeof v === 'string' ? v : f.display_name;
38
+ }
@@ -0,0 +1,167 @@
1
+ // FormWith (form_with over the framework-neutral form model), the field component of each Flex type,
2
+ // and ErrorMessages (the platform error code's message, `errors.codes.<code>`, and the fields' full
3
+ // messages). Every visible string is a translation: labels are human attribute names, the submit label
4
+ // is helpers.submit.*, the relation prompt helpers.select.prompt.
5
+ import { createContext, createElement as h, useContext, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
6
+ import { createForm, fullMessage, submitLabel } from '../helpers/form.js';
7
+ import { humanAttributeName } from '../model.js';
8
+ import { api } from '../api.js';
9
+ import { recordLabel } from '../resource.js';
10
+ import { useI18n } from './i18n.js';
11
+ import { useSite } from './router.js';
12
+
13
+ const FormContext = createContext(null);
14
+
15
+ function useFormState(form) {
16
+ return useSyncExternalStore(form.subscribe, form.getState, form.getState);
17
+ }
18
+
19
+ /** The form model and its state inside a FormWith. */
20
+ export function useForm() {
21
+ const f = useContext(FormContext);
22
+ if (!f) throw new Error('a field component renders inside <FormWith>');
23
+ return f;
24
+ }
25
+
26
+ function Field({ name, children, input }) {
27
+ const { form, state } = useForm();
28
+ const i18n = useI18n();
29
+ const errors = state.errors[name] ?? [];
30
+ return h(
31
+ 'div',
32
+ { className: errors.length ? 'field field_with_errors' : 'field' },
33
+ h('label', { htmlFor: `${form.resource.entity}_${name}` }, humanAttributeName(form.resource, name, { i18n })),
34
+ input,
35
+ errors.length ? h('p', { className: 'field-error', role: 'alert' }, errors.join(', ')) : null,
36
+ children,
37
+ );
38
+ }
39
+
40
+ function inputProps(name, type) {
41
+ const { form, state } = useForm();
42
+ const field = form.fields.find((f) => f.name === name);
43
+ if (!field) throw new Error(`resource "${form.resource.entity}" has no field "${name}"`);
44
+ return {
45
+ id: `${form.resource.entity}_${name}`,
46
+ name,
47
+ required: field.required,
48
+ value: state.values[name],
49
+ onChange: (e) => form.set(name, e.target.value),
50
+ type,
51
+ 'aria-invalid': (state.errors[name] ?? []).length ? 'true' : undefined,
52
+ };
53
+ }
54
+
55
+ const typed = (type, extra = {}) =>
56
+ function TypedField({ name }) {
57
+ return h(Field, { name, input: h('input', { ...inputProps(name, type), ...extra }) });
58
+ };
59
+
60
+ export const TextField = typed('text');
61
+ export const IntegerField = typed('number', { step: 1 });
62
+ export const DecimalField = typed('text', { inputMode: 'decimal' });
63
+ export const DateField = typed('date');
64
+ export const DateTimeField = typed('datetime-local');
65
+ export const FileField = typed('text');
66
+
67
+ export function BooleanField({ name }) {
68
+ const { form, state } = useForm();
69
+ return h(Field, {
70
+ name,
71
+ input: h('input', {
72
+ id: `${form.resource.entity}_${name}`,
73
+ name,
74
+ type: 'checkbox',
75
+ checked: Boolean(state.values[name]),
76
+ onChange: (e) => form.set(name, e.target.checked),
77
+ }),
78
+ });
79
+ }
80
+
81
+ export function JsonField({ name }) {
82
+ return h(Field, { name, input: h('textarea', inputProps(name, undefined)) });
83
+ }
84
+
85
+ export const VectorField = JsonField;
86
+
87
+ /** A relation: a select over the target resource's records (their label field). */
88
+ export function RelationField({ name }) {
89
+ const { form } = useForm();
90
+ const i18n = useI18n();
91
+ const { projection } = useSite();
92
+ const field = form.fields.find((f) => f.name === name);
93
+ const [options, setOptions] = useState([]);
94
+ const prefix = field?.relation?.target_prefix;
95
+ const target = field?.relation ? projection?.resources?.[field.relation.target] : undefined;
96
+ useEffect(() => {
97
+ if (!prefix) return;
98
+ api('GET', `${prefix}?limit=200`).then((r) => setOptions(r?.records ?? []), () => setOptions([]));
99
+ }, [prefix]);
100
+ const props = inputProps(name, undefined);
101
+ delete props.type;
102
+ return h(Field, {
103
+ name,
104
+ input: h(
105
+ 'select',
106
+ props,
107
+ h('option', { value: '' }, i18n.t('helpers.select.prompt')),
108
+ options.map((r) => h('option', { key: r.id, value: r.id }, target ? recordLabel(target, r) : r.id)),
109
+ ),
110
+ });
111
+ }
112
+
113
+ /** The field component of a Flex type. */
114
+ export function fieldFor(type) {
115
+ return (
116
+ {
117
+ text: TextField, integer: IntegerField, decimal: DecimalField, boolean: BooleanField, date: DateField,
118
+ datetime: DateTimeField, json: JsonField, vector: VectorField, relation: RelationField, file: FileField,
119
+ }[type] ?? TextField
120
+ );
121
+ }
122
+
123
+ /**
124
+ * <FormWith resource={pet} record={record} onSubmit={(payload, form) => …} fields={{name: MyInput}}>
125
+ * — without children it renders every field of the projection, then the submit button.
126
+ */
127
+ export function FormWith({ resource, record, form: given, onSubmit, fields = {}, children, className = 'form' }) {
128
+ const i18n = useI18n();
129
+ const form = useMemo(() => given ?? createForm(resource, { record, i18n }), [given, resource, record, i18n]);
130
+ const state = useFormState(form);
131
+ const value = useMemo(() => ({ form, state }), [form, state]);
132
+ function submit(event) {
133
+ event.preventDefault();
134
+ form.submit(onSubmit ?? (() => undefined));
135
+ }
136
+ const body =
137
+ children ??
138
+ h(
139
+ 'div',
140
+ null,
141
+ form.fields.map((f) => h(fields[f.name] ?? fieldFor(f.type), { key: f.name, name: f.name })),
142
+ h('button', { type: 'submit', disabled: state.status === 'submitting' }, submitLabel(form.resource, form.id !== undefined, { i18n })),
143
+ );
144
+ return h(
145
+ FormContext.Provider,
146
+ { value },
147
+ h('form', { className, onSubmit: submit, noValidate: true }, h(ErrorMessages, { error: state.base[0], resource: form.resource, errors: state.errors }), body),
148
+ );
149
+ }
150
+
151
+ /**
152
+ * <ErrorMessages error={apiError} /> — the platform code's message (`errors.codes.<code>`, spec S4 §
153
+ * Locale files) and, with `resource` and `errors`, each field's full message.
154
+ */
155
+ export function ErrorMessages({ error, resource, errors = {} }) {
156
+ const i18n = useI18n();
157
+ const fieldMessages = resource
158
+ ? Object.entries(errors).flatMap(([field, list]) => list.map((m) => fullMessage(resource, field, m, { i18n })))
159
+ : [];
160
+ if (!error && fieldMessages.length === 0) return null;
161
+ return h(
162
+ 'div',
163
+ { className: 'error-messages', role: 'alert' },
164
+ error ? h('p', null, i18n.t(`errors.codes.${error.code}`)) : null,
165
+ fieldMessages.length ? h('ul', null, fieldMessages.map((m) => h('li', { key: m }, m))) : null,
166
+ );
167
+ }
@@ -0,0 +1,52 @@
1
+ // I18nProvider: negotiates the page's locale (spec S4 § Locale negotiation — the `locale` parameter,
2
+ // the #632 preference hook, navigator.languages by RFC 4647 lookup, the default), binds the i18n
3
+ // instance the helpers render with, and wires the run-time missing-key reporter to
4
+ // POST /_air/i18n-missing (bounded per key per page load).
5
+ import { createContext, createElement as h, useContext, useMemo } from 'react';
6
+ import { createI18n, createMissingReporter, negotiateLocale } from '../i18n/index.js';
7
+ import { setCurrentI18n } from '../i18n/current.js';
8
+
9
+ const I18nContext = createContext(null);
10
+
11
+ /**
12
+ * @param {{bundle: object, release?: string, locale?: string, url?: string, languages?: string[],
13
+ * preferred?: () => string|undefined, reporter?: (key: string) => void, children?: any}} props
14
+ */
15
+ export function I18nProvider({ bundle, release, locale, url, languages, preferred, reporter, children }) {
16
+ const i18n = useMemo(() => {
17
+ const chosen =
18
+ locale ??
19
+ negotiateLocale({
20
+ url: url ?? (typeof location !== 'undefined' ? location.href : undefined),
21
+ languages: languages ?? (typeof navigator !== 'undefined' ? navigator.languages : []),
22
+ available: bundle.available_locales,
23
+ defaultLocale: bundle.default_locale,
24
+ preferred,
25
+ });
26
+ const report = reporter ?? (release ? createMissingReporter({ release, locale: chosen }) : undefined);
27
+ const instance = createI18n({ bundle, locale: chosen, reporter: report });
28
+ setCurrentI18n(instance);
29
+ return instance;
30
+ }, [bundle, release, locale, url, languages, preferred, reporter]);
31
+ return h(I18nContext.Provider, { value: i18n }, children);
32
+ }
33
+
34
+ /** The i18n instance of the nearest provider (or page scope). */
35
+ export function useI18n() {
36
+ const i = useContext(I18nContext);
37
+ if (!i) throw new Error('useI18n: no I18nProvider above this component');
38
+ return i;
39
+ }
40
+
41
+ /** `t` bound to the nearest provider — lazy keys resolve against the page's scope. */
42
+ export function useT() {
43
+ const i = useI18n();
44
+ return i.t;
45
+ }
46
+
47
+ /** Provide the lazy-lookup scope of a page (`pages/<route>/index.jsx` → `<route>.index`). */
48
+ export function PageScope({ page, children }) {
49
+ const parent = useI18n();
50
+ const scoped = useMemo(() => parent.withLazy({ site_page: `pages/${page}/index.jsx` }), [parent, page]);
51
+ return h(I18nContext.Provider, { value: scoped }, children);
52
+ }
@@ -0,0 +1,21 @@
1
+ // @aircloudhq/web/react — the React bindings (ADR-222 D3; spec S4 § @aircloudhq/web public modules).
2
+ export { I18nProvider, PageScope, useI18n, useT } from './i18n.js';
3
+ export { Layout, SiteRouter, useSite } from './router.js';
4
+ export { ButtonTo, LinkTo } from './links.js';
5
+ export {
6
+ BooleanField,
7
+ DateField,
8
+ DateTimeField,
9
+ DecimalField,
10
+ ErrorMessages,
11
+ FileField,
12
+ FormWith,
13
+ IntegerField,
14
+ JsonField,
15
+ RelationField,
16
+ TextField,
17
+ VectorField,
18
+ fieldFor,
19
+ useForm,
20
+ } from './form.js';
21
+ export { Resource, ResourceForm, ResourceList, ResourceShow, defaultColumns, resourcePage, useDisplay } from './resource.js';
@@ -0,0 +1,56 @@
1
+ // LinkTo and ButtonTo: link_to / button_to as components. The URL goes through urlFor, so the
2
+ // current page's `locale` parameter travels with every link (ADR-223 D7).
3
+ import { createElement as h } from 'react';
4
+ import { Link, useInRouterContext, useLocation, useNavigate, useResolvedPath } from 'react-router';
5
+ import { api } from '../api.js';
6
+ import { urlFor } from '../helpers/url.js';
7
+
8
+ function useHere() {
9
+ const inRouter = useInRouterContext();
10
+ const loc = inRouter ? useLocation() : null; // stable per tree position: the router context never appears or vanishes under a mounted component
11
+ if (loc) return `http://x${loc.pathname}${loc.search}${loc.hash}`;
12
+ return typeof location !== 'undefined' ? location.href : undefined;
13
+ }
14
+
15
+ // A relative target resolves against the route hierarchy (React Router's rule), then gains the locale.
16
+ function useTarget(to) {
17
+ const here = useHere();
18
+ const inRouter = useInRouterContext();
19
+ const resolved = inRouter ? useResolvedPath(to) : null; // stable per tree position (see useHere)
20
+ const path = resolved ? `${resolved.pathname}${resolved.search}${resolved.hash}` : to;
21
+ return urlFor(path, { currentUrl: here });
22
+ }
23
+
24
+ /** <LinkTo to="/profiles/1">Profile</LinkTo> — link_to "Profile", "/profiles/1" */
25
+ export function LinkTo({ to, children, ...props }) {
26
+ const href = useTarget(to);
27
+ if (useInRouterContext()) return h(Link, { to: href, ...props }, children ?? href);
28
+ return h('a', { href, ...props }, children ?? href);
29
+ }
30
+
31
+ /**
32
+ * <ButtonTo to="/api/pets/1" method="delete" onDone={…}>Delete</ButtonTo> — button_to: a one-button
33
+ * form. `get` navigates; any other method calls the API (with `params` as the body) and hands the
34
+ * result to `onDone`, or `onError` the ApiError.
35
+ */
36
+ export function ButtonTo({ to, method = 'post', params, onDone, onError, children, className, formClass = 'button_to', ...props }) {
37
+ const inRouter = useInRouterContext();
38
+ const navigate = inRouter ? useNavigate() : null; // stable per tree position: the router context never appears or vanishes under a mounted component
39
+ const action = useTarget(to);
40
+ const m = String(method).toLowerCase();
41
+ function onSubmit(event) {
42
+ event.preventDefault();
43
+ if (m === 'get') {
44
+ if (navigate) navigate(action);
45
+ else location.assign(action);
46
+ return;
47
+ }
48
+ api(m.toUpperCase(), action, params).then((r) => onDone?.(r), (e) => (onError ? onError(e) : undefined));
49
+ }
50
+ return h(
51
+ 'form',
52
+ { className: formClass, method: m === 'get' ? 'get' : 'post', action, onSubmit },
53
+ ['patch', 'put', 'delete'].includes(m) ? h('input', { type: 'hidden', name: '_method', value: m, autoComplete: 'off' }) : null,
54
+ h('button', { type: 'submit', className, ...props }, children),
55
+ );
56
+ }
@@ -0,0 +1,225 @@
1
+ // Resource pages (ADR-222 D5; spec S4 § Resource-page override points): a scaffold page is one line —
2
+ // `export default resourcePage("pet")` — and the full CRUD interface comes from the resource projection.
3
+ // The Builder's choices are overrides in the page:
4
+ //
5
+ // listColumns: string[] fields in the list, in order (default: the label field,
6
+ // then the next fields in schema order, up to five)
7
+ // fields: {[field]: Component} the input component of one field in the form
8
+ // display: {[field]: (value, record) => node} how one field shows in list and show views
9
+ // List, Show, Form replace a whole view (props: resource, Default, …)
10
+ // layout: string a layout other than `application`
11
+ //
12
+ // Every visible string is a translation (the human model and attribute names, helpers.resource.*,
13
+ // helpers.submit.*); a value renders through `l` and the number helpers of the current locale.
14
+ import { createElement as h, useEffect, useMemo, useState } from 'react';
15
+ import { Route, Routes, useNavigate, useParams } from 'react-router';
16
+ import { createResource, recordLabel } from '../resource.js';
17
+ import { humanAttributeName, modelName } from '../model.js';
18
+ import { useI18n } from './i18n.js';
19
+ import { useSite } from './router.js';
20
+ import { ErrorMessages, FormWith } from './form.js';
21
+ import { ButtonTo, LinkTo } from './links.js';
22
+
23
+ /** The listColumns default (spec S4 § Constants, Provisional): label field first, up to five. */
24
+ export const LIST_COLUMNS_MAX = 5;
25
+
26
+ export function defaultColumns(resource) {
27
+ const names = resource.fields.map((f) => f.name);
28
+ const first = resource.label_field && names.includes(resource.label_field) ? [resource.label_field] : [];
29
+ return [...first, ...names.filter((n) => !first.includes(n))].slice(0, LIST_COLUMNS_MAX);
30
+ }
31
+
32
+ /** A stored value as the current locale renders it. */
33
+ export function useDisplay(resource, display = {}) {
34
+ const i18n = useI18n();
35
+ return (field, record) => {
36
+ const value = record?.data?.[field];
37
+ if (display[field]) return display[field](value, record);
38
+ if (value === undefined || value === null || value === '') return '';
39
+ const type = resource.fields.find((f) => f.name === field)?.type;
40
+ switch (type) {
41
+ case 'date':
42
+ return i18n.l(String(value));
43
+ case 'datetime':
44
+ return i18n.l(new Date(value));
45
+ case 'integer':
46
+ case 'decimal':
47
+ return i18n.formatNumber('delimited', value);
48
+ case 'boolean':
49
+ return i18n.t(`helpers.resource.boolean.${value ? 'true' : 'false'}`);
50
+ case 'json':
51
+ case 'vector':
52
+ return JSON.stringify(value);
53
+ default:
54
+ return String(value);
55
+ }
56
+ };
57
+ }
58
+
59
+ function useResourceOf(name) {
60
+ const { projection } = useSite();
61
+ const r = projection?.resources?.[name];
62
+ if (!r) throw new Error(`the resource projection has no resource "${name}"`);
63
+ return r;
64
+ }
65
+
66
+ /** The default list view. */
67
+ export function ResourceList({ resource, listColumns, display, base = '.' }) {
68
+ const i18n = useI18n();
69
+ const client = useMemo(() => createResource(resource), [resource]);
70
+ const [records, setRecords] = useState([]);
71
+ const [cursor, setCursor] = useState(null);
72
+ const [error, setError] = useState(null);
73
+ const show = useDisplay(resource, display);
74
+ const columns = listColumns ?? defaultColumns(resource);
75
+ const load = (after) =>
76
+ client.list({ cursor: after ?? undefined }).then((page) => {
77
+ setRecords((prev) => (after ? prev.concat(page.records) : page.records));
78
+ setCursor(page.cursor);
79
+ }, setError);
80
+ useEffect(() => {
81
+ load(null);
82
+ }, [client]);
83
+ const model = modelName(resource, { i18n });
84
+ return h(
85
+ 'section',
86
+ { className: 'resource-list' },
87
+ h('h1', null, model.human({ count: 2 })),
88
+ h(ErrorMessages, { error }),
89
+ h('p', null, h(LinkTo, { to: `${base}/new`, className: 'button' }, i18n.t('helpers.resource.new', { model: model.human() }))),
90
+ h(
91
+ 'table',
92
+ null,
93
+ h('thead', null, h('tr', null, columns.map((c) => h('th', { key: c }, humanAttributeName(resource, c, { i18n }))))),
94
+ h(
95
+ 'tbody',
96
+ null,
97
+ records.map((r) =>
98
+ h(
99
+ 'tr',
100
+ { key: r.id },
101
+ columns.map((c, i) =>
102
+ h('td', { key: c }, i === 0 ? h(LinkTo, { to: `${base}/${encodeURIComponent(r.id)}` }, show(c, r) || recordLabel(resource, r)) : show(c, r)),
103
+ ),
104
+ ),
105
+ ),
106
+ ),
107
+ ),
108
+ cursor ? h('p', null, h('button', { type: 'button', onClick: () => load(cursor) }, i18n.t('helpers.resource.more'))) : null,
109
+ );
110
+ }
111
+
112
+ /** The default show view. */
113
+ export function ResourceShow({ resource, display, id, base = '..' }) {
114
+ const i18n = useI18n();
115
+ const navigate = useNavigate();
116
+ const client = useMemo(() => createResource(resource), [resource]);
117
+ const [record, setRecord] = useState(null);
118
+ const [error, setError] = useState(null);
119
+ const show = useDisplay(resource, display);
120
+ useEffect(() => {
121
+ client.get(id).then(setRecord, setError);
122
+ }, [client, id]);
123
+ return h(
124
+ 'section',
125
+ { className: 'resource-show' },
126
+ h('h1', null, record ? recordLabel(resource, record) : modelName(resource, { i18n }).human()),
127
+ h(ErrorMessages, { error }),
128
+ record
129
+ ? h(
130
+ 'dl',
131
+ null,
132
+ resource.fields.flatMap((f) => [
133
+ h('dt', { key: `${f.name}-t` }, humanAttributeName(resource, f.name, { i18n })),
134
+ h('dd', { key: `${f.name}-d` }, show(f.name, record)),
135
+ ]),
136
+ )
137
+ : null,
138
+ h(
139
+ 'div',
140
+ { className: 'actions' },
141
+ h(LinkTo, { to: `${base}/${encodeURIComponent(id)}/edit`, className: 'button' }, i18n.t('helpers.resource.edit')),
142
+ h(
143
+ ButtonTo,
144
+ {
145
+ to: `${resource.route_prefix}/${encodeURIComponent(id)}`,
146
+ method: 'delete',
147
+ onDone: () => navigate(base),
148
+ onError: setError,
149
+ className: 'button-danger',
150
+ },
151
+ i18n.t('helpers.resource.destroy'),
152
+ ),
153
+ h(LinkTo, { to: base }, i18n.t('helpers.resource.back')),
154
+ ),
155
+ );
156
+ }
157
+
158
+ /** The default form view (new when `id` is absent, edit otherwise). */
159
+ export function ResourceForm({ resource, fields, id, base = '..' }) {
160
+ const i18n = useI18n();
161
+ const navigate = useNavigate();
162
+ const client = useMemo(() => createResource(resource), [resource]);
163
+ const [record, setRecord] = useState(id === undefined ? null : undefined);
164
+ const [error, setError] = useState(null);
165
+ useEffect(() => {
166
+ if (id !== undefined) client.get(id).then(setRecord, setError);
167
+ }, [client, id]);
168
+ const model = modelName(resource, { i18n });
169
+ if (record === undefined) return h(ErrorMessages, { error });
170
+ return h(
171
+ 'section',
172
+ { className: 'resource-form' },
173
+ h('h1', null, id === undefined ? i18n.t('helpers.resource.new', { model: model.human() }) : `${i18n.t('helpers.resource.edit')} ${recordLabel(resource, record)}`),
174
+ h(ErrorMessages, { error }),
175
+ h(FormWith, {
176
+ resource,
177
+ record: record ?? undefined,
178
+ fields,
179
+ onSubmit: (payload) =>
180
+ (id === undefined ? client.create(payload) : client.update(id, payload)).then((r) => {
181
+ navigate(`${base}/${encodeURIComponent(r.id)}`);
182
+ return r;
183
+ }),
184
+ }),
185
+ h('p', null, h(LinkTo, { to: id === undefined ? base : `${base}/${encodeURIComponent(id)}` }, i18n.t('helpers.resource.cancel'))),
186
+ );
187
+ }
188
+
189
+ function ShowRoute({ resource, options }) {
190
+ const { id } = useParams();
191
+ const View = options.Show ?? ResourceShow;
192
+ return h(View, { resource, id, display: options.display, Default: ResourceShow, base: '..' });
193
+ }
194
+
195
+ function EditRoute({ resource, options }) {
196
+ const { id } = useParams();
197
+ const View = options.Form ?? ResourceForm;
198
+ return h(View, { resource, id, fields: options.fields, Default: ResourceForm, base: '..' });
199
+ }
200
+
201
+ /** The routes of one resource: list, new, show, edit (Rails `resources`). */
202
+ export function Resource({ name, options = {} }) {
203
+ const resource = useResourceOf(name);
204
+ const List = options.List ?? ResourceList;
205
+ const Form = options.Form ?? ResourceForm;
206
+ return h(
207
+ Routes,
208
+ null,
209
+ h(Route, { index: true, element: h(List, { resource, listColumns: options.listColumns, display: options.display, Default: ResourceList, base: '.' }) }),
210
+ h(Route, { path: 'new', element: h(Form, { resource, fields: options.fields, Default: ResourceForm, base: '..' }) }),
211
+ h(Route, { path: ':id', element: h(ShowRoute, { resource, options }) }),
212
+ h(Route, { path: ':id/edit', element: h(EditRoute, { resource, options }) }),
213
+ );
214
+ }
215
+
216
+ /** export default resourcePage("pet", { listColumns: ["name", "species"] }) */
217
+ export function resourcePage(name, options = {}) {
218
+ function ResourcePage() {
219
+ return h(Resource, { name, options });
220
+ }
221
+ ResourcePage.displayName = `resourcePage(${name})`;
222
+ // The router wrapper renders a page in `Component.layout` when its route names none.
223
+ if (options.layout) ResourcePage.layout = options.layout;
224
+ return ResourcePage;
225
+ }
@@ -0,0 +1,44 @@
1
+ // The router wrapper over React Router (a pinned lockfile dependency — never copied source) and the
2
+ // layouts (Rails `layouts/application`). A site's src/routes.js declares its named routes:
3
+ //
4
+ // export default [{ name: "membros", path: "/membros/*", page: "membros", Component: MembrosPage }]
5
+ //
6
+ // and src/main.jsx renders <SiteRouter routes={routes} layouts={layouts} projection={projection} />.
7
+ // Each page renders inside its layout and its lazy-lookup scope (`t(".title")` in
8
+ // pages/membros/index.jsx is `membros.index.title`).
9
+ import { createContext, createElement as h, useContext } from 'react';
10
+ import { BrowserRouter, MemoryRouter, Route, Routes } from 'react-router';
11
+ import { PageScope } from './i18n.js';
12
+
13
+ const SiteContext = createContext({ layouts: {}, projection: null });
14
+
15
+ /** The layouts and the resource projection the site renders with. */
16
+ export function useSite() {
17
+ return useContext(SiteContext);
18
+ }
19
+
20
+ /** A named layout (default `application`); `site.layout_missing` when absent. */
21
+ export function Layout({ name = 'application', children }) {
22
+ const { layouts } = useSite();
23
+ const L = layouts?.[name];
24
+ if (!L) throw new Error(`layout "${name}" does not exist (src/layouts/${name}.jsx)`);
25
+ return h(L, null, children);
26
+ }
27
+
28
+ /**
29
+ * @param {{routes: Array<{name: string, path: string, page: string, Component: any, layout?: string}>,
30
+ * layouts?: Record<string, any>, projection?: object, basename?: string, initialEntries?: string[]}} props
31
+ * `initialEntries` renders over a memory history (tests and previews).
32
+ */
33
+ export function SiteRouter({ routes, layouts = {}, projection = null, basename, initialEntries }) {
34
+ const children = routes.map((r) =>
35
+ h(Route, {
36
+ key: r.name,
37
+ path: r.path,
38
+ element: h(Layout, { name: r.layout ?? r.Component.layout ?? 'application' }, h(PageScope, { page: r.page ?? r.name }, h(r.Component))),
39
+ }),
40
+ );
41
+ const table = h(Routes, null, children);
42
+ const router = initialEntries ? h(MemoryRouter, { initialEntries, basename }, table) : h(BrowserRouter, { basename }, table);
43
+ return h(SiteContext.Provider, { value: { layouts, projection } }, router);
44
+ }
@@ -0,0 +1,57 @@
1
+ // Framework-neutral resource operations over a resource's CRUD route (Rails `resources`): the route
2
+ // prefix, the fields and the label field come from the resource projection
3
+ // (contracts/deploy/v1/resource-projection-v1.json), never from a hand-kept field list.
4
+ //
5
+ // GET <prefix>?cursor=&limit= list -> {records: [{id, data}], cursor}
6
+ // GET <prefix>/<id> get -> {id, data}
7
+ // POST <prefix> create -> {id, data}
8
+ // PATCH <prefix>/<id> update -> {id, data}
9
+ // DELETE <prefix>/<id> destroy
10
+ import { api as defaultApi } from './api.js';
11
+
12
+ /** The projection resource `name` of `projection` (a resource-projection-v1 document). */
13
+ export function projectionResource(projection, name) {
14
+ const r = projection?.resources?.[name];
15
+ if (!r) throw new Error(`the resource projection has no resource "${name}"`);
16
+ return r;
17
+ }
18
+
19
+ /** A resource client. */
20
+ export function createResource(resource, options = {}) {
21
+ const call = options.api ?? defaultApi;
22
+ const prefix = resource.route_prefix;
23
+ if (!prefix) throw new Error(`resource "${resource.entity}" has no route (site.resource_unrouted)`);
24
+ const item = (id) => `${prefix}/${encodeURIComponent(id)}`;
25
+ return {
26
+ resource,
27
+ /** list({cursor, limit, filter: {field: value}}) -> {records, cursor} */
28
+ list({ cursor, limit, filter } = {}) {
29
+ const q = new URLSearchParams();
30
+ for (const [k, v] of Object.entries(filter ?? {})) q.set(k, String(v));
31
+ if (cursor) q.set('cursor', cursor);
32
+ if (limit) q.set('limit', String(limit));
33
+ const s = q.toString();
34
+ return call('GET', s ? `${prefix}?${s}` : prefix).then((r) => ({ records: r?.records ?? [], cursor: r?.cursor ?? null }));
35
+ },
36
+ /** Every page, in order: for await (const page of pets.pages()) … */
37
+ async *pages(params = {}) {
38
+ let cursor = params.cursor ?? null;
39
+ do {
40
+ const page = await this.list({ ...params, cursor });
41
+ yield page.records;
42
+ cursor = page.cursor;
43
+ } while (cursor);
44
+ },
45
+ get: (id) => call('GET', item(id)),
46
+ create: (data) => call('POST', prefix, data),
47
+ update: (id, data) => call('PATCH', item(id), data),
48
+ destroy: (id) => call('DELETE', item(id)),
49
+ };
50
+ }
51
+
52
+ /** A record's label: its label field's value, else its id. */
53
+ export function recordLabel(resource, record) {
54
+ const f = resource.label_field;
55
+ const v = f ? record?.data?.[f] : undefined;
56
+ return v === undefined || v === null || v === '' ? String(record?.id ?? '') : String(v);
57
+ }