@aircloudhq/web 1.0.2 → 1.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/README.md +17 -1
- package/package.json +3 -1
- package/src/assertion.js +114 -0
- package/src/function.js +62 -1
- package/src/helpers/form.js +20 -3
- package/src/helpers/number.js +10 -8
- package/src/i18n/index.js +86 -18
- package/src/i18n/missing.js +30 -0
- package/src/model.js +15 -1
- package/src/principal.js +58 -0
- package/src/react/data.js +145 -0
- package/src/react/form.js +42 -8
- package/src/react/i18n.js +6 -3
- package/src/react/index.js +17 -1
- package/src/react/resource.js +107 -43
package/README.md
CHANGED
|
@@ -82,6 +82,22 @@ import { resourcePage } from "@aircloudhq/web/react";
|
|
|
82
82
|
export default resourcePage("pet");
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Routing inside a page uses the router primitives `@aircloudhq/web/react` re-exports — `Link`, `NavLink`,
|
|
86
|
+
`Routes`, `Route`, `Outlet`, `Navigate`, `useLocation`, `useNavigate`, `useParams`, `useSearchParams` — the
|
|
87
|
+
same React Router instance the `SiteRouter` renders with. Import them from `@aircloudhq/web/react`, never from
|
|
88
|
+
`react-router`: a second copy of the router breaks its context.
|
|
89
|
+
|
|
90
|
+
Dates and times render through `l` (`useI18n().l`, or `l` from `@aircloudhq/web/helpers/translation`), never
|
|
91
|
+
`Intl`. A named format is looked up by what the value is:
|
|
92
|
+
|
|
93
|
+
- an **instant** — a `Date` or a timestamp — reads `time.formats.<name>` and renders in the product's time zone;
|
|
94
|
+
- a **civil date** — a `"YYYY-MM-DD"` string — reads `date.formats.<name>`.
|
|
95
|
+
|
|
96
|
+
So `l(new Date(), { format: "weekday" })` reads `time.formats.weekday`; for `date.formats.weekday` pass the civil
|
|
97
|
+
date (`l("2026-03-08", { format: "weekday" })`). A format no locale of the chain defines renders humanized and
|
|
98
|
+
is **reported**, like a missing key: with a `release`, to the product host; with none (a dev server, a preview),
|
|
99
|
+
`I18nProvider` warns on the console, naming the rule.
|
|
100
|
+
|
|
85
101
|
A function answers in the caller's locale (`app/functions/pet/index.js`):
|
|
86
102
|
|
|
87
103
|
```js
|
|
@@ -98,7 +114,7 @@ async function methodNotAllowed(request) {
|
|
|
98
114
|
|
|
99
115
|
| Import | What it carries |
|
|
100
116
|
|---|---|
|
|
101
|
-
| `@aircloudhq/web/react` | `SiteRouter`, `I18nProvider`, `useT`, `useSite`, `LinkTo`, `FormWith`, `resourcePage` |
|
|
117
|
+
| `@aircloudhq/web/react` | `SiteRouter`, `I18nProvider`, `useT`, `useSite`, `LinkTo`, `FormWith`, `resourcePage`, the data hooks `useList` / `useRecord`, and the router primitives (`Link`, `Routes`, `useParams`, …) |
|
|
102
118
|
| `@aircloudhq/web/helpers/number` | `numberToCurrency`, `numberToPercentage`, `numberWithDelimiter`, … |
|
|
103
119
|
| `@aircloudhq/web/helpers/date` | `timeAgoInWords`, `distanceOfTimeInWords`, `distanceOfTimeInWordsToNow` |
|
|
104
120
|
| `@aircloudhq/web/helpers/text`, `…/tag`, `…/url`, `…/form`, `…/sanitize`, `…/navigation` | the Action View helper modules of the same name |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aircloudhq/web",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "The application package of an Air-Cloud product: the Rails Action View helpers, i18n over the product's locale bundle, resource pages over its models, and React bindings, for its site and its JavaScript functions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aircloud",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./src/index.js",
|
|
32
|
+
"./principal": "./src/principal.js",
|
|
33
|
+
"./assertion": "./src/assertion.js",
|
|
32
34
|
"./helpers/number": "./src/helpers/number.js",
|
|
33
35
|
"./helpers/date": "./src/helpers/date.js",
|
|
34
36
|
"./helpers/text": "./src/helpers/text.js",
|
package/src/assertion.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @aircloudhq/web/assertion — a CONTAINER's verification of the platform principal assertion (#632
|
|
2
|
+
// ADR-241 decision 4). The web-origin holds the end-user session; an authenticated request reaches the
|
|
3
|
+
// Builder's container with `x-air-principal`, an ES256 JWS the platform signed under its dedicated
|
|
4
|
+
// `web-origin-principal` key, and never the session cookie or a token. The container verifies it here
|
|
5
|
+
// with the same semantics as the functions-host route trigger (functions/src/caps_principal.rs):
|
|
6
|
+
//
|
|
7
|
+
// import { verifyPrincipal } from '@aircloudhq/web/assertion';
|
|
8
|
+
// const me = await verifyPrincipal(request); // throws on anything but a valid assertion
|
|
9
|
+
//
|
|
10
|
+
// Checked: alg ES256 under a key of the principal key set (`jwksFile` — default AIR_PRINCIPAL_JWKS_FILE,
|
|
11
|
+
// the file the platform projects into every container from the key set its control plane reconciles into
|
|
12
|
+
// the tenant namespace; a container never fetches it, so it needs no path to a platform API), the issuer,
|
|
13
|
+
// `aud` = the product host the request names, `tenant_id` = this product's tenant (default
|
|
14
|
+
// AIR_TENANT_ID), a lifetime of at most 60 s that has not ended, and a well-formed principal. A rotation
|
|
15
|
+
// rewrites the file: an assertion naming a kid the cached set lacks re-reads it once. Fail-closed: a
|
|
16
|
+
// missing setting, an absent or malformed key set or any mismatch throws.
|
|
17
|
+
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
export const PRINCIPAL_HEADER = 'x-air-principal';
|
|
21
|
+
export const PRINCIPAL_ISSUER = 'aircloud-web-origin-principal';
|
|
22
|
+
/** == functions/src/caps_principal.rs MAX_ASSERTION_LIFETIME_SECS / ASSERTION_CLOCK_SKEW_SECS. */
|
|
23
|
+
const MAX_LIFETIME_SECS = 60;
|
|
24
|
+
const CLOCK_SKEW_SECS = 5;
|
|
25
|
+
const JWKS_MAX_AGE_MS = 300_000;
|
|
26
|
+
|
|
27
|
+
const cache = new Map(); // jwksFile → { keys, at }
|
|
28
|
+
|
|
29
|
+
const env = (k) => (typeof process !== 'undefined' && process.env ? process.env[k] : undefined);
|
|
30
|
+
|
|
31
|
+
function b64uDecode(s) {
|
|
32
|
+
return Uint8Array.from(Buffer.from(s, 'base64url'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parse(token) {
|
|
36
|
+
const parts = typeof token === 'string' ? token.split('.') : [];
|
|
37
|
+
if (parts.length !== 3) throw new Error('the principal assertion is not a compact JWS');
|
|
38
|
+
const [h, p, s] = parts;
|
|
39
|
+
const json = (seg) => JSON.parse(new TextDecoder().decode(b64uDecode(seg)));
|
|
40
|
+
return { header: json(h), claims: json(p), input: new TextEncoder().encode(`${h}.${p}`), sig: b64uDecode(s) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readKeySet(file, now) {
|
|
44
|
+
const doc = JSON.parse(await readFile(file, 'utf8'));
|
|
45
|
+
if (!doc || !Array.isArray(doc.keys)) throw new Error(`the principal key set ${file} is not a JWK set`);
|
|
46
|
+
cache.set(file, { keys: doc.keys, at: now });
|
|
47
|
+
return doc.keys;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The key `kid` of the set at `file`: cached, re-read when stale or when the cached set lacks the kid. */
|
|
51
|
+
async function keyFor(file, kid, now) {
|
|
52
|
+
const hit = cache.get(file);
|
|
53
|
+
const fresh = hit && now - hit.at < JWKS_MAX_AGE_MS;
|
|
54
|
+
let found = fresh ? hit.keys.find((k) => k.kid === kid) : undefined;
|
|
55
|
+
if (!found) found = (await readKeySet(file, now)).find((k) => k.kid === kid);
|
|
56
|
+
if (!found) throw new Error(`no principal key '${kid}' in ${file}`);
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const text = (v, what) => {
|
|
61
|
+
if (v === undefined || v === null) return null;
|
|
62
|
+
if (typeof v !== 'string') throw new Error(`the assertion's ${what} is not text`);
|
|
63
|
+
return v;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Verify the platform principal assertion of `request` (a Fetch API Request, or headers) and return the
|
|
68
|
+
* end user it asserts.
|
|
69
|
+
* @param {Request|{headers: Headers}} request
|
|
70
|
+
* @param {{jwksFile?: string, tenantId?: string, issuer?: string, host?: string, now?: () => number}} [options]
|
|
71
|
+
* @returns {Promise<{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null, flash: string|null, csrf: string|null}>}
|
|
72
|
+
*/
|
|
73
|
+
export async function verifyPrincipal(request, options = {}) {
|
|
74
|
+
const jwksFile = options.jwksFile ?? env('AIR_PRINCIPAL_JWKS_FILE');
|
|
75
|
+
const tenantId = options.tenantId ?? env('AIR_TENANT_ID');
|
|
76
|
+
const issuer = options.issuer ?? PRINCIPAL_ISSUER;
|
|
77
|
+
const now = (options.now ?? (() => Math.floor(Date.now() / 1000)))();
|
|
78
|
+
if (!jwksFile) throw new Error('verifyPrincipal: no key-set file (AIR_PRINCIPAL_JWKS_FILE) — refusing (fail-closed)');
|
|
79
|
+
if (!tenantId) throw new Error('verifyPrincipal: no tenant (AIR_TENANT_ID) — refusing (fail-closed)');
|
|
80
|
+
const headers = request.headers;
|
|
81
|
+
const host = (options.host ?? headers.get('x-forwarded-host') ?? headers.get('host') ?? '').toLowerCase();
|
|
82
|
+
const token = headers.get(PRINCIPAL_HEADER);
|
|
83
|
+
if (!token) throw new Error('the request carries no principal assertion');
|
|
84
|
+
|
|
85
|
+
const { header, claims: c, input, sig } = parse(token);
|
|
86
|
+
if (header.alg !== 'ES256' || typeof header.kid !== 'string') throw new Error('the assertion is not ES256 with a kid');
|
|
87
|
+
const jwk = await keyFor(jwksFile, header.kid, now * 1000);
|
|
88
|
+
const { kid, alg, use, key_ops: _ops, ...material } = jwk;
|
|
89
|
+
const pub = await crypto.subtle.importKey('jwk', material, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
90
|
+
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, pub, sig, input);
|
|
91
|
+
if (!ok) throw new Error('the assertion does not verify');
|
|
92
|
+
|
|
93
|
+
if (c.iss !== issuer) throw new Error('the assertion names another issuer');
|
|
94
|
+
if (!host || c.aud !== host) throw new Error('the assertion is for another product host');
|
|
95
|
+
if (!Number.isInteger(c.iat) || !Number.isInteger(c.exp) || c.exp <= c.iat || c.exp - c.iat > MAX_LIFETIME_SECS) {
|
|
96
|
+
throw new Error('the assertion lifetime is out of bounds');
|
|
97
|
+
}
|
|
98
|
+
if (now >= c.exp) throw new Error('the assertion has expired');
|
|
99
|
+
if (Math.max(c.iat, c.nbf ?? 0) > now + CLOCK_SKEW_SECS) throw new Error('the assertion is not valid yet');
|
|
100
|
+
if (c.tenant_id !== tenantId) throw new Error('the assertion is for another tenant');
|
|
101
|
+
if (typeof c.org_path !== 'string' || !c.org_path.startsWith('/')) throw new Error('the assertion carries no org_path');
|
|
102
|
+
if (typeof c.sub !== 'string' || c.sub === '') throw new Error('the assertion carries no sub');
|
|
103
|
+
const roles = c.roles ?? [];
|
|
104
|
+
if (!Array.isArray(roles) || roles.some((r) => typeof r !== 'string')) throw new Error("the assertion's roles are not strings");
|
|
105
|
+
return {
|
|
106
|
+
sub: c.sub,
|
|
107
|
+
tenantId: c.tenant_id,
|
|
108
|
+
orgPath: c.org_path,
|
|
109
|
+
roles,
|
|
110
|
+
act: c.act ?? null,
|
|
111
|
+
flash: text(c.flash, 'flash'),
|
|
112
|
+
csrf: text(c.csrf, 'csrf'),
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/function.js
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
// const i18n = await i18nFor(request, { function: 'greet' });
|
|
9
9
|
// i18n.t('.title'); // functions.greet.title in the negotiated locale
|
|
10
10
|
//
|
|
11
|
+
// A user-facing error is a code and its details, never a sentence (#250 operator decision D37):
|
|
12
|
+
//
|
|
13
|
+
// return errorResponse(request, 409, { code: 'slot_taken', details: { slot } }, { function: 'book' });
|
|
14
|
+
//
|
|
15
|
+
// answers the platform envelope {"error":{"code","message","correlation_id","details"}} whose message is the
|
|
16
|
+
// code's translation in the caller's locale — the function's own functions.<name>.errors.<code>, else the
|
|
17
|
+
// platform's errors.codes.<code> — interpolated from the details' scalar values. It accepts nothing else:
|
|
18
|
+
// a string, a message or any other key is a TypeError, so free text cannot reach an end user.
|
|
19
|
+
//
|
|
11
20
|
// Negotiation, in S4's order: (1) the end-user and organisation preference — reserved for #632;
|
|
12
21
|
// (2) the request's Accept-Language, RFC 4647 lookup; (3) for a trigger without a request (an
|
|
13
22
|
// `x-air-trigger: event` delivery), the `locale` key of the event payload; (4) default_locale. The
|
|
@@ -18,7 +27,7 @@
|
|
|
18
27
|
// A key no locale resolves renders humanized and is reported through the capability, which alarms
|
|
19
28
|
// once per key per release.
|
|
20
29
|
import { bundle, keyMissing, locales } from 'aircloud:functions/i18n@1.0.0';
|
|
21
|
-
import { acceptLanguage, createI18n, lookupLocale } from './i18n/index.js';
|
|
30
|
+
import { acceptLanguage, createI18n, lookupLocale, OPTION_KEYS } from './i18n/index.js';
|
|
22
31
|
|
|
23
32
|
function canonical(available, tag) {
|
|
24
33
|
if (typeof tag !== 'string') return undefined;
|
|
@@ -71,3 +80,55 @@ export async function i18nFor(request, { function: name } = {}) {
|
|
|
71
80
|
},
|
|
72
81
|
});
|
|
73
82
|
}
|
|
83
|
+
|
|
84
|
+
/** An error code: dot-separated snake_case segments (`slot_taken`, `flex.validation`). */
|
|
85
|
+
const CODE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
86
|
+
|
|
87
|
+
function isPlainObject(v) {
|
|
88
|
+
return v !== null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The user-facing error, checked: exactly {code, details?}, a code that is a code, details an object. */
|
|
92
|
+
function userError(error) {
|
|
93
|
+
if (!isPlainObject(error)) throw new TypeError('errorResponse: the error is {code, details}, never a message');
|
|
94
|
+
for (const k of Object.keys(error)) {
|
|
95
|
+
if (k !== 'code' && k !== 'details') throw new TypeError(`errorResponse: the error takes only {code, details}, not \`${k}\``);
|
|
96
|
+
}
|
|
97
|
+
if (typeof error.code !== 'string' || !CODE.test(error.code)) {
|
|
98
|
+
throw new TypeError(`errorResponse: ${JSON.stringify(error.code)} is not an error code (snake_case segments)`);
|
|
99
|
+
}
|
|
100
|
+
const details = error.details ?? {};
|
|
101
|
+
if (!isPlainObject(details)) throw new TypeError('errorResponse: details is an object');
|
|
102
|
+
return { code: error.code, details };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The response of a user-facing error (D37): the platform envelope with the code's translated message.
|
|
107
|
+
* @param {Request} request the invocation's request (its locale, its x-correlation-id)
|
|
108
|
+
* @param {number} status the HTTP status
|
|
109
|
+
* @param {{code: string, details?: object}} error the code and its details — nothing else
|
|
110
|
+
* @param {{function?: string}} [options] the function's name, the scope of its own error keys
|
|
111
|
+
* @returns {Promise<Response>}
|
|
112
|
+
*/
|
|
113
|
+
export async function errorResponse(request, status, error, { function: name } = {}) {
|
|
114
|
+
const { code, details } = userError(error);
|
|
115
|
+
let message;
|
|
116
|
+
try {
|
|
117
|
+
const i18n = await i18nFor(request, { function: name });
|
|
118
|
+
const args = {};
|
|
119
|
+
for (const [k, v] of Object.entries(details)) {
|
|
120
|
+
if (!OPTION_KEYS.has(k) && ['string', 'number', 'boolean'].includes(typeof v)) args[k] = v;
|
|
121
|
+
}
|
|
122
|
+
const platform = `errors.codes.${code}`;
|
|
123
|
+
message = name ? i18n.t(`.errors.${code}`, { ...args, default: [{ key: platform }] }) : i18n.t(platform, args);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// An error response is always answered: when the bundle cannot be read, the message is the refusal's code.
|
|
126
|
+
if (typeof e?.payload?.code !== 'string') throw e;
|
|
127
|
+
message = e.payload.code;
|
|
128
|
+
}
|
|
129
|
+
const correlationId = request.headers.get('x-correlation-id') ?? '';
|
|
130
|
+
return new Response(JSON.stringify({ error: { code, message, correlation_id: correlationId, details } }), {
|
|
131
|
+
status,
|
|
132
|
+
headers: { 'content-type': 'application/json' },
|
|
133
|
+
});
|
|
134
|
+
}
|
package/src/helpers/form.js
CHANGED
|
@@ -100,10 +100,22 @@ export function fullMessage(resource, field, message, options = {}) {
|
|
|
100
100
|
/**
|
|
101
101
|
* createForm(resource, {record, i18n}) — the form model of one resource: a new record, or the edit of
|
|
102
102
|
* `record` ({id, data}).
|
|
103
|
+
*
|
|
104
|
+
* A new record starts each field at its projected literal default (ADR-259 decision 9); a generator
|
|
105
|
+
* default has no value until Flex evaluates it, so its field starts empty. An edit starts from the stored
|
|
106
|
+
* values, never the default. On create, a defaulted field the user did not touch is omitted from the
|
|
107
|
+
* payload (Flex fills the default in force for the record's subtree) and passes the client-side
|
|
108
|
+
* required check; a defaulted field the user cleared is sent as null, so the default does not return.
|
|
103
109
|
*/
|
|
104
110
|
export function createForm(resource, options = {}) {
|
|
105
111
|
const i18n = () => i18nFor(options);
|
|
106
|
-
const
|
|
112
|
+
const start = (f) => {
|
|
113
|
+
if (options.record) return options.record.data?.[f.name];
|
|
114
|
+
return f.default && 'value' in f.default ? f.default.value : undefined;
|
|
115
|
+
};
|
|
116
|
+
const initial = Object.fromEntries(resource.fields.map((f) => [f.name, toInput(f.type, start(f))]));
|
|
117
|
+
// A defaulted field the user has not touched, on a new record: Flex applies its default.
|
|
118
|
+
const defaulted = (f) => model.id === undefined && Boolean(f.default) && !state.touched[f.name];
|
|
107
119
|
let state = {
|
|
108
120
|
values: { ...initial },
|
|
109
121
|
initial: { ...initial },
|
|
@@ -147,19 +159,24 @@ export function createForm(resource, options = {}) {
|
|
|
147
159
|
validate() {
|
|
148
160
|
const errors = {};
|
|
149
161
|
for (const f of resource.fields) {
|
|
162
|
+
if (defaulted(f)) continue;
|
|
150
163
|
const kind = validationKind(f, state.values[f.name]);
|
|
151
164
|
if (kind) errors[f.name] = [errorMessage(resource, f.name, kind, { i18n: i18n() })];
|
|
152
165
|
}
|
|
153
166
|
setState({ errors });
|
|
154
167
|
return Object.keys(errors).length === 0;
|
|
155
168
|
},
|
|
156
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* The JSON body of the submit: set fields; on an update a cleared field is null. On a create an
|
|
171
|
+
* untouched defaulted field is omitted and a cleared one is null.
|
|
172
|
+
*/
|
|
157
173
|
payload() {
|
|
158
174
|
const data = {};
|
|
159
175
|
for (const f of resource.fields) {
|
|
176
|
+
if (defaulted(f)) continue;
|
|
160
177
|
const v = toValue(f.type, state.values[f.name]);
|
|
161
178
|
if (v !== undefined) data[f.name] = v;
|
|
162
|
-
else if (model.id !== undefined) data[f.name] = null;
|
|
179
|
+
else if (model.id !== undefined || f.default) data[f.name] = null;
|
|
163
180
|
}
|
|
164
181
|
return data;
|
|
165
182
|
},
|
package/src/helpers/number.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// locale bundle's `number.*` keys: the same option names (camelCase accepted as well), the same
|
|
3
3
|
// defaults, BigDecimal half-up rounding. Without a bound i18n instance the Rails defaults apply.
|
|
4
4
|
import { i18nFor } from '../i18n/current.js';
|
|
5
|
-
import { NUMBER_DEFAULTS, formatDelimited, formatRounded } from '../i18n/index.js';
|
|
6
|
-
import {
|
|
5
|
+
import { NUMBER_DEFAULTS, formatDelimited, formatRounded, moneyValue, renderCurrency } from '../i18n/index.js';
|
|
6
|
+
import { parseDecimal } from '../i18n/decimal.js';
|
|
7
7
|
|
|
8
8
|
const SNAKE = {
|
|
9
9
|
stripInsignificantZeros: 'strip_insignificant_zeros',
|
|
@@ -30,13 +30,15 @@ function lookup(options, key) {
|
|
|
30
30
|
return i18nFor(options)?.lookup(key);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* number_to_currency(1234567890.50) # => "$1,234,567,890.50" — a plain number in the product currency,
|
|
35
|
+
* a monetary value `{amount: "79.90", currency: "USD"}` in its own (ADR-255 decision 8).
|
|
36
|
+
*/
|
|
34
37
|
export function numberToCurrency(number, options = {}) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
return format.replace('%n', rounded).replace('%u', o.unit);
|
|
38
|
+
const i = i18nFor(options);
|
|
39
|
+
if (i) return i.formatNumber('currency', number, snake(options));
|
|
40
|
+
const money = moneyValue(number);
|
|
41
|
+
return renderCurrency(parseDecimal(money ? money.amount : number), optionsFor('currency', options));
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** number_with_delimiter(12345678) # => "12,345,678" */
|
package/src/i18n/index.js
CHANGED
|
@@ -11,13 +11,14 @@ import { zoneAt } from './zone.js';
|
|
|
11
11
|
export { pluralCategory, operands } from './plural.js';
|
|
12
12
|
export { zoneAt } from './zone.js';
|
|
13
13
|
export { negotiateLocale, lookupLocale, acceptLanguage, localeFromUrl } from './negotiate.js';
|
|
14
|
-
export { createMissingReporter } from './missing.js';
|
|
14
|
+
export { createDevReporter, createMissingReporter } from './missing.js';
|
|
15
15
|
|
|
16
16
|
/** The locale that ends every run-time chain with the platform defaults (S4 § [i18n]). */
|
|
17
17
|
export const PLATFORM_TERMINAL_LOCALE = 'en';
|
|
18
18
|
|
|
19
19
|
const PLURAL_KEYS = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
20
|
-
|
|
20
|
+
/** The keys of a `t` call's options; every other key is an interpolation argument. */
|
|
21
|
+
export const OPTION_KEYS = new Set(['scope', 'default', 'count', 'lazy', 'context', 'locale']);
|
|
21
22
|
|
|
22
23
|
// ── chains ───────────────────────────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -229,10 +230,19 @@ export function localize(bundle, locale, value, options = {}, report = () => {})
|
|
|
229
230
|
|
|
230
231
|
// ── numbers ──────────────────────────────────────────────────────────────────────────────────
|
|
231
232
|
|
|
232
|
-
/**
|
|
233
|
+
/** CLDR's generic currency sign (ISO 4217 XXX, "no currency"): the unit when none resolves (ADR-255 D5). */
|
|
234
|
+
export const GENERIC_CURRENCY_SIGN = '¤';
|
|
235
|
+
/** The key a currency rendering with no currency reports through the missing-key channel (ADR-255 D5). */
|
|
236
|
+
export const CURRENCY_MISSING_KEY = 'number.currency.code';
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* ActiveSupport::NumberHelper::NumberConverter::DEFAULTS — except the currency unit, which is never a
|
|
240
|
+
* locale or platform default (ADR-255): `¤`, CLDR's generic currency sign, stands until the product
|
|
241
|
+
* currency or the call's own `unit` resolves it.
|
|
242
|
+
*/
|
|
233
243
|
export const NUMBER_DEFAULTS = {
|
|
234
244
|
format: { separator: '.', delimiter: ',', precision: 3, significant: false, strip_insignificant_zeros: false },
|
|
235
|
-
currency: { format: '%u%n', negative_format: '-%u%n', unit:
|
|
245
|
+
currency: { format: '%u%n', negative_format: '-%u%n', unit: GENERIC_CURRENCY_SIGN, separator: '.', delimiter: ',', precision: 2, significant: false, strip_insignificant_zeros: false },
|
|
236
246
|
percentage: { delimiter: '', format: '%n%' },
|
|
237
247
|
precision: { delimiter: '' },
|
|
238
248
|
human: {
|
|
@@ -242,18 +252,82 @@ export const NUMBER_DEFAULTS = {
|
|
|
242
252
|
storage_units: { format: '%n %u', units: { byte: 'Bytes', kb: 'KB', mb: 'MB', gb: 'GB', tb: 'TB', pb: 'PB', eb: 'EB', zb: 'ZB' } },
|
|
243
253
|
};
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* A monetary value — `{amount, currency}`, a decimal string and the ISO 4217 code it is in (ADR-255
|
|
257
|
+
* decision 8, D34: an amount carries its own currency, and the product currency never restates it) —
|
|
258
|
+
* or null for a plain number. A value naming a currency whose amount is not a decimal string (G12:
|
|
259
|
+
* never a binary float) or whose code is not ISO 4217-shaped is refused.
|
|
260
|
+
*/
|
|
261
|
+
export function moneyValue(value) {
|
|
262
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, 'currency')) return null;
|
|
263
|
+
const { amount, currency } = value;
|
|
264
|
+
if (typeof amount !== 'string') {
|
|
265
|
+
throw new TypeError(`a monetary value's amount is a decimal string such as "79.90", not ${JSON.stringify(amount)}`);
|
|
266
|
+
}
|
|
267
|
+
parseDecimal(amount);
|
|
268
|
+
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) {
|
|
269
|
+
throw new TypeError(`a monetary value's currency is an ISO 4217 code such as "USD", not ${JSON.stringify(currency)}`);
|
|
270
|
+
}
|
|
271
|
+
return { amount, currency };
|
|
272
|
+
}
|
|
273
|
+
|
|
245
274
|
/**
|
|
246
275
|
* The effective options of a number style (Rails' i18n_format_options): defaults, then the first
|
|
247
276
|
* `number.format` hash of the locale's declared chain (else its CLDR symbols), then the first
|
|
248
|
-
* `number.<namespace>.format` hash of the run-time chain, then the call's options.
|
|
277
|
+
* `number.<namespace>.format` hash of the run-time chain, then the call's options. For `currency`
|
|
278
|
+
* the unit and precision then resolve from the value's own currency (`valueCurrency`, a monetary
|
|
279
|
+
* value's code) or the product currency (currencyOptions).
|
|
249
280
|
*/
|
|
250
|
-
export function numberOptions(bundle, locale, namespace, options = {}) {
|
|
281
|
+
export function numberOptions(bundle, locale, namespace, options = {}, report = () => {}, valueCurrency = undefined) {
|
|
251
282
|
const base = firstIn(bundle, declaredChain(bundle, locale), 'number.format', isObject)?.value
|
|
252
283
|
?? cldrNumberFormat(bundle, locale)
|
|
253
284
|
?? firstIn(bundle, runtimeChain(bundle, locale), 'number.format', isObject)?.value
|
|
254
285
|
?? {};
|
|
255
286
|
const ns = namespace ? firstIn(bundle, runtimeChain(bundle, locale), `number.${namespace}.format`, isObject)?.value ?? {} : {};
|
|
256
|
-
|
|
287
|
+
const o = { ...NUMBER_DEFAULTS.format, ...(namespace ? NUMBER_DEFAULTS[namespace] ?? {} : {}), ...base, ...ns, ...options };
|
|
288
|
+
return namespace === 'currency' ? currencyOptions(bundle, locale, o, options, report, valueCurrency) : o;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The currency of a rendering (ADR-255 decisions 2, 3, 5, 8). The currency is the value's own (a
|
|
293
|
+
* monetary value's code, which must be in the bundle's active set) else the product currency. The unit
|
|
294
|
+
* is the call's explicit `unit`, else that currency's CLDR symbol in this locale, else `¤` — reported
|
|
295
|
+
* once as `number.currency.code`, never a guessed currency; a locale file's `unit` is not read. The
|
|
296
|
+
* precision is the call's explicit `precision`, else that currency's minor unit (CLDR's DEFAULT when
|
|
297
|
+
* there is none); a locale file's `precision` is not read. Where the symbol directly touches the number
|
|
298
|
+
* (`%u%n`, `%n%u`) the locale's CLDR currencySpacing is inserted.
|
|
299
|
+
*/
|
|
300
|
+
function currencyOptions(bundle, locale, o, options, report, valueCurrency) {
|
|
301
|
+
const facts = bundle.currency;
|
|
302
|
+
if (valueCurrency !== undefined && !Object.hasOwn(facts?.minor_units ?? {}, valueCurrency)) {
|
|
303
|
+
throw new RangeError(`currency ${valueCurrency} is not an ISO 4217 currency in tender use at the bundle's CLDR`);
|
|
304
|
+
}
|
|
305
|
+
const code = valueCurrency ?? facts?.code ?? null;
|
|
306
|
+
const minor = code ? facts.minor_units?.[code] : facts?.default_minor_unit;
|
|
307
|
+
const out = { ...o, precision: options.precision ?? minor ?? o.precision };
|
|
308
|
+
if (options.unit !== undefined) return out;
|
|
309
|
+
const symbol = code ? facts.symbols?.[locale]?.[code] : undefined;
|
|
310
|
+
if (!symbol) {
|
|
311
|
+
report(CURRENCY_MISSING_KEY);
|
|
312
|
+
return { ...out, unit: GENERIC_CURRENCY_SIGN };
|
|
313
|
+
}
|
|
314
|
+
const spaced = (format) => format.replace('%u%n', `%u${symbol.prefix_spacing}%n`).replace('%n%u', `%n${symbol.suffix_spacing}%u`);
|
|
315
|
+
return { ...out, unit: symbol.symbol, format: spaced(out.format), negative_format: spaced(out.negative_format) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** number_to_currency's rendering of a decimal over resolved currency options. */
|
|
319
|
+
export function renderCurrency(d, o) {
|
|
320
|
+
const rounded = formatRounded(abs(d), o);
|
|
321
|
+
const zero = /^[^1-9]*$/.test(rounded);
|
|
322
|
+
const format = d.neg && !zero ? o.negative_format : o.format;
|
|
323
|
+
return format.replace('%n', rounded).replace('%u', o.unit);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** number_to_currency over the bundle: a plain number in the product currency, a monetary value in its own. */
|
|
327
|
+
export function formatCurrency(bundle, locale, value, options = {}, report = () => {}) {
|
|
328
|
+
const money = moneyValue(value);
|
|
329
|
+
const o = numberOptions(bundle, locale, 'currency', options, report, money?.currency);
|
|
330
|
+
return renderCurrency(parseDecimal(money ? money.amount : value), o);
|
|
257
331
|
}
|
|
258
332
|
|
|
259
333
|
function cldrNumberFormat(bundle, locale) {
|
|
@@ -278,7 +352,7 @@ export function formatRounded(value, o) {
|
|
|
278
352
|
}
|
|
279
353
|
|
|
280
354
|
/** The shared number styles: currency | delimited | precision | percentage. */
|
|
281
|
-
export function formatNumber(bundle, locale, style, value, options = {}) {
|
|
355
|
+
export function formatNumber(bundle, locale, style, value, options = {}, report = () => {}) {
|
|
282
356
|
switch (style) {
|
|
283
357
|
case 'delimited':
|
|
284
358
|
return formatDelimited(value, numberOptions(bundle, locale, null, options));
|
|
@@ -288,14 +362,8 @@ export function formatNumber(bundle, locale, style, value, options = {}) {
|
|
|
288
362
|
const o = numberOptions(bundle, locale, 'percentage', options);
|
|
289
363
|
return o.format.replace('%n', formatRounded(value, o));
|
|
290
364
|
}
|
|
291
|
-
case 'currency':
|
|
292
|
-
|
|
293
|
-
const d = parseDecimal(value);
|
|
294
|
-
const rounded = formatRounded(abs(d), o);
|
|
295
|
-
const zero = /^[^1-9]*$/.test(rounded);
|
|
296
|
-
const format = d.neg && !zero ? o.negative_format : o.format;
|
|
297
|
-
return format.replace('%n', rounded).replace('%u', o.unit);
|
|
298
|
-
}
|
|
365
|
+
case 'currency':
|
|
366
|
+
return formatCurrency(bundle, locale, value, options, report);
|
|
299
367
|
default:
|
|
300
368
|
throw new Error(`formatNumber: unknown style ${style}`);
|
|
301
369
|
}
|
|
@@ -355,8 +423,8 @@ export function createI18n({ bundle, locale, reporter, lazy } = {}) {
|
|
|
355
423
|
locale: current,
|
|
356
424
|
t: (key, options = {}) => translate(bundle, current, key, { lazy, ...options }, report),
|
|
357
425
|
l: (value, options = {}) => localize(bundle, current, value, options, report),
|
|
358
|
-
formatNumber: (style, value, options) => formatNumber(bundle, current, style, value, options),
|
|
359
|
-
numberOptions: (namespace, options) => numberOptions(bundle, current, namespace, options),
|
|
426
|
+
formatNumber: (style, value, options) => formatNumber(bundle, current, style, value, options, report),
|
|
427
|
+
numberOptions: (namespace, options) => numberOptions(bundle, current, namespace, options, report),
|
|
360
428
|
pluralize: (count, singular, plural) => pluralizeWord(bundle, current, count, singular, plural),
|
|
361
429
|
exists: (key, options = {}) => resolve(bundle, current, key, { lazy, ...options }).found,
|
|
362
430
|
/** The raw value of a key through the run-time chain (a hash, an array, a string), or undefined. */
|
package/src/i18n/missing.js
CHANGED
|
@@ -33,3 +33,33 @@ export function createMissingReporter({ release, locale, send }) {
|
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
const FORMAT_KEY = /^(date|time)\.formats\.(.+)$/;
|
|
38
|
+
|
|
39
|
+
/** The rule a missing date/time format breaks (#664 item 28): an instant reads time.formats, a civil date date.formats. */
|
|
40
|
+
function formatHint(key) {
|
|
41
|
+
const m = FORMAT_KEY.exec(key);
|
|
42
|
+
if (!m) return '';
|
|
43
|
+
const [, kind, name] = m;
|
|
44
|
+
return kind === 'time'
|
|
45
|
+
? ` A Date or a timestamp is an instant and reads time.formats.${name}; pass a civil date as "YYYY-MM-DD" to read date.formats.${name}.`
|
|
46
|
+
: ` A "YYYY-MM-DD" string is a civil date and reads date.formats.${name}; an instant (a Date) reads time.formats.${name}.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The reporter of a page with no release to report to (a dev server, a preview, a test): a key still
|
|
51
|
+
* unresolved after its chain is rendered humanized and warned on the console — never silently — once per
|
|
52
|
+
* key per page load (at most MAX_KEYS_PER_PAGE_LOAD keys); a missing date/time format names the
|
|
53
|
+
* instant-vs-civil-date rule.
|
|
54
|
+
* @param {{locale: string, warn?: (message: string) => void}} init `warn` defaults to console.warn
|
|
55
|
+
* @returns {(key: string) => void}
|
|
56
|
+
*/
|
|
57
|
+
export function createDevReporter({ locale, warn }) {
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const out = warn ?? ((message) => console.warn(message));
|
|
60
|
+
return (key) => {
|
|
61
|
+
if (seen.has(key) || seen.size >= MAX_KEYS_PER_PAGE_LOAD) return;
|
|
62
|
+
seen.add(key);
|
|
63
|
+
out(`@aircloudhq/web i18n: missing "${key}" (locale ${locale}) — rendered humanized.${formatHint(key)}`);
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/model.js
CHANGED
|
@@ -30,8 +30,22 @@ export function modelName(resource, options = {}) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Model.human_attribute_name(:field), and human_attribute_name("field.value") for an enum value (ADR-258
|
|
35
|
+
* decision 7): the nested key `activemodel.attributes.<entity>/<field>.<value>`, else the value's
|
|
36
|
+
* projection display_name (the default-locale label), else the value itself — a value the projection
|
|
37
|
+
* does not know renders as itself.
|
|
38
|
+
*/
|
|
34
39
|
export function humanAttributeName(resource, field, options = {}) {
|
|
40
|
+
const dot = field.indexOf('.');
|
|
41
|
+
if (dot >= 0) {
|
|
42
|
+
const name = field.slice(0, dot);
|
|
43
|
+
const value = field.slice(dot + 1);
|
|
44
|
+
const f = fieldOf(resource, name);
|
|
45
|
+
const v = i18nFor(options)?.lookupDeclared(`activemodel.attributes.${resource.entity}/${name}.${value}`);
|
|
46
|
+
if (typeof v === 'string') return v;
|
|
47
|
+
return f.enum?.values?.find((x) => x.value === value)?.display_name ?? value;
|
|
48
|
+
}
|
|
35
49
|
const f = fieldOf(resource, field);
|
|
36
50
|
const v = i18nFor(options)?.lookupDeclared(`activemodel.attributes.${resource.entity}.${field}`);
|
|
37
51
|
return typeof v === 'string' ? v : f.display_name;
|
package/src/principal.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @aircloudhq/web/principal — the end user of a `session` route, its flash and its CSRF proof, for a
|
|
2
|
+
// JavaScript function (#632 ADR-241 decision 4, plan W15.4/W15.7; the Rails `current_user`-shaped
|
|
3
|
+
// principal, `flash` and `csrf_meta_tags`, ADR-221 D4).
|
|
4
|
+
//
|
|
5
|
+
// The web-origin holds the end-user session; the function never sees the session cookie or a token.
|
|
6
|
+
// The functions host hands the guest what the platform asserted, through the
|
|
7
|
+
// `aircloud:functions/principal` interface, and this module reads it:
|
|
8
|
+
//
|
|
9
|
+
// import { principal, flash, setFlash, csrfMeta } from '@aircloudhq/web/principal';
|
|
10
|
+
// const me = principal(); // null on a public route
|
|
11
|
+
// const { notice } = flash(); // the message the previous request set (read once)
|
|
12
|
+
// setFlash({ notice: 'Saved' }); // the next request's message
|
|
13
|
+
// `<head>${csrfMeta()}</head>` // the token a form or fetch sends as X-CSRF-Token
|
|
14
|
+
//
|
|
15
|
+
// The CSRF check is the session edge's (the web-origin): an unsafe request is accepted when its
|
|
16
|
+
// `Origin` is the product's, and a client that sends no `Origin` proves it with the session's token.
|
|
17
|
+
import { csrfToken as wireCsrf, current, flash as wireFlash, setFlash as wireSetFlash } from 'aircloud:functions/principal@1.0.0';
|
|
18
|
+
|
|
19
|
+
const escapeAttr = (s) =>
|
|
20
|
+
String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The end user this invocation serves, or null outside a `session` route.
|
|
24
|
+
* @returns {{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null}|null}
|
|
25
|
+
*/
|
|
26
|
+
export function principal() {
|
|
27
|
+
const p = current();
|
|
28
|
+
if (!p) return null;
|
|
29
|
+
return { sub: p.sub, tenantId: p.tenantId, orgPath: p.orgPath, roles: [...p.roles], act: p.act ? JSON.parse(p.act) : null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The flash the previous request of this session set (`{}` when none) — the platform clears it. */
|
|
33
|
+
export function flash() {
|
|
34
|
+
const f = wireFlash();
|
|
35
|
+
if (!f) return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(f);
|
|
38
|
+
} catch {
|
|
39
|
+
return { notice: f }; // a flash a container set as plain text
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Set the flash the next request of this session reads (an object, stored as JSON). */
|
|
44
|
+
export function setFlash(message) {
|
|
45
|
+
wireSetFlash(JSON.stringify(message));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The session's CSRF token, or null outside a `session` route. */
|
|
49
|
+
export function csrfToken() {
|
|
50
|
+
return wireCsrf() ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The Rails `csrf_meta_tags` pair carrying the session's token; empty outside a session. */
|
|
54
|
+
export function csrfMeta() {
|
|
55
|
+
const t = csrfToken();
|
|
56
|
+
if (!t) return '';
|
|
57
|
+
return `<meta name="csrf-param" content="authenticity_token" />\n<meta name="csrf-token" content="${escapeAttr(t)}" />`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Data hooks over a resource's CRUD route (#664 item 15): the React side of `@aircloudhq/web/resource`, for
|
|
2
|
+
// pages that are not a whole resourcePage. A resource is its entity name (resolved in the site's resource
|
|
3
|
+
// projection) or the projection resource itself.
|
|
4
|
+
//
|
|
5
|
+
// const { records, cursor, loading, error, more, reload } = useList("pet", { filter: { owner: id } });
|
|
6
|
+
// const { record, loading, error, reload } = useRecord("pet", id);
|
|
7
|
+
//
|
|
8
|
+
// The typed per-entity client is Air-Reflect's typed SDK (#246, ADR-083); these hooks read the projection.
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
import { createResource, projectionResource, recordLabel } from '../resource.js';
|
|
11
|
+
import { useSite } from './router.js';
|
|
12
|
+
|
|
13
|
+
/** The projection resource a hook names: an entity name, the resource itself, or null (no resource). */
|
|
14
|
+
export function useResource(resource) {
|
|
15
|
+
const { projection } = useSite();
|
|
16
|
+
if (resource === null || resource === undefined) return null;
|
|
17
|
+
return typeof resource === 'string' ? projectionResource(projection, resource) : resource;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The records of a resource, page by page (`cursor` paging; `more()` appends the next page).
|
|
22
|
+
* @param {string|object|null} resource null lists nothing
|
|
23
|
+
* @param {{filter?: Record<string, string>, limit?: number}} [query] `filter` compares fields by equality
|
|
24
|
+
*/
|
|
25
|
+
export function useList(resource, query = {}) {
|
|
26
|
+
const r = useResource(resource);
|
|
27
|
+
const client = useMemo(() => (r ? createResource(r) : null), [r]);
|
|
28
|
+
const key = JSON.stringify({ filter: query.filter ?? {}, limit: query.limit ?? null });
|
|
29
|
+
const [state, setState] = useState({ records: [], cursor: null, loading: Boolean(client), error: null });
|
|
30
|
+
const generation = useRef(0);
|
|
31
|
+
const load = useCallback(
|
|
32
|
+
(after) => {
|
|
33
|
+
if (!client) return Promise.resolve();
|
|
34
|
+
const g = generation.current;
|
|
35
|
+
const { filter, limit } = JSON.parse(key);
|
|
36
|
+
setState((s) => ({ ...s, loading: true }));
|
|
37
|
+
return client.list({ cursor: after ?? undefined, filter, limit: limit ?? undefined }).then(
|
|
38
|
+
(page) => {
|
|
39
|
+
if (g !== generation.current) return;
|
|
40
|
+
setState((s) => ({ records: after ? s.records.concat(page.records) : page.records, cursor: page.cursor, loading: false, error: null }));
|
|
41
|
+
},
|
|
42
|
+
(error) => {
|
|
43
|
+
if (g !== generation.current) return;
|
|
44
|
+
setState((s) => ({ ...s, loading: false, error }));
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
[client, key],
|
|
49
|
+
);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
generation.current += 1;
|
|
52
|
+
setState({ records: [], cursor: null, loading: Boolean(client), error: null });
|
|
53
|
+
load(null);
|
|
54
|
+
}, [load]);
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
/** The next page appended; a no-op on the last page. */
|
|
58
|
+
more: () => (state.cursor ? load(state.cursor) : Promise.resolve()),
|
|
59
|
+
/** The first page again. */
|
|
60
|
+
reload: () => load(null),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One record of a resource by id; an undefined id reads nothing (`record` null).
|
|
66
|
+
* @param {string|object} resource
|
|
67
|
+
* @param {string|undefined} id
|
|
68
|
+
*/
|
|
69
|
+
export function useRecord(resource, id) {
|
|
70
|
+
const r = useResource(resource);
|
|
71
|
+
const client = useMemo(() => createResource(r), [r]);
|
|
72
|
+
const [state, setState] = useState({ record: null, loading: id !== undefined, error: null });
|
|
73
|
+
const generation = useRef(0);
|
|
74
|
+
const load = useCallback(() => {
|
|
75
|
+
if (id === undefined) return Promise.resolve();
|
|
76
|
+
const g = generation.current;
|
|
77
|
+
setState((s) => ({ ...s, loading: true }));
|
|
78
|
+
return client.get(id).then(
|
|
79
|
+
(record) => g === generation.current && setState({ record, loading: false, error: null }),
|
|
80
|
+
(error) => g === generation.current && setState({ record: null, loading: false, error }),
|
|
81
|
+
);
|
|
82
|
+
}, [client, id]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
generation.current += 1;
|
|
85
|
+
setState({ record: null, loading: id !== undefined, error: null });
|
|
86
|
+
load();
|
|
87
|
+
}, [load]);
|
|
88
|
+
return { ...state, reload: load };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The labels of the related records the given records point to, one read per distinct related record:
|
|
93
|
+
* `(field, id) => label | undefined` (undefined until read, or when the target is not routed).
|
|
94
|
+
*/
|
|
95
|
+
export function useRelationLabels(resource, records) {
|
|
96
|
+
const { projection } = useSite();
|
|
97
|
+
const wanted = useMemo(() => {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const f of resource.fields) {
|
|
100
|
+
const target = f.relation ? projection?.resources?.[f.relation.target] : undefined;
|
|
101
|
+
if (!target?.route_prefix) continue;
|
|
102
|
+
const ids = new Set(records.map((rec) => rec?.data?.[f.name]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
103
|
+
for (const id of ids) out.push({ target, id: String(id) });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}, [resource, records, projection]);
|
|
107
|
+
const [labels, setLabels] = useState({});
|
|
108
|
+
const requested = useRef(new Set());
|
|
109
|
+
const mounted = useRef(true);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
mounted.current = true;
|
|
112
|
+
return () => {
|
|
113
|
+
mounted.current = false;
|
|
114
|
+
};
|
|
115
|
+
}, []);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
for (const { target, id } of wanted) {
|
|
118
|
+
const k = `${target.entity}/${id}`;
|
|
119
|
+
if (requested.current.has(k)) continue;
|
|
120
|
+
requested.current.add(k);
|
|
121
|
+
const settle = (label) => mounted.current && setLabels((m) => ({ ...m, [k]: label }));
|
|
122
|
+
createResource(target)
|
|
123
|
+
.get(id)
|
|
124
|
+
.then((rec) => settle(recordLabel(target, rec)), () => settle(null));
|
|
125
|
+
}
|
|
126
|
+
}, [wanted]);
|
|
127
|
+
return (field, id) => {
|
|
128
|
+
const f = resource.fields.find((x) => x.name === field);
|
|
129
|
+
const target = f?.relation?.target;
|
|
130
|
+
const label = target === undefined ? undefined : labels[`${target}/${id}`];
|
|
131
|
+
return label ?? undefined;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The has-many relations of a resource: every relation field of a resource of the projection whose target
|
|
137
|
+
* is it, in projection order — [{entity, field}].
|
|
138
|
+
*/
|
|
139
|
+
export function hasManyRelations(projection, name) {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const [entity, r] of Object.entries(projection?.resources ?? {})) {
|
|
142
|
+
for (const f of r.fields) if (f.relation?.target === name) out.push({ entity, field: f.name });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
package/src/react/form.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// and ErrorMessages (the platform error code's message, `errors.codes.<code>`, and the fields' full
|
|
3
3
|
// messages). Every visible string is a translation: labels are human attribute names, the submit label
|
|
4
4
|
// is helpers.submit.*, the relation prompt helpers.select.prompt.
|
|
5
|
-
import { createContext, createElement as h, useContext,
|
|
5
|
+
import { createContext, createElement as h, useContext, useMemo, useSyncExternalStore } from 'react';
|
|
6
6
|
import { createForm, fullMessage, submitLabel } from '../helpers/form.js';
|
|
7
7
|
import { humanAttributeName } from '../model.js';
|
|
8
|
-
import { api } from '../api.js';
|
|
9
8
|
import { recordLabel } from '../resource.js';
|
|
10
9
|
import { useI18n } from './i18n.js';
|
|
11
10
|
import { useSite } from './router.js';
|
|
11
|
+
import { useList } from './data.js';
|
|
12
|
+
|
|
13
|
+
/** The records a relation select offers: one Flex query page at its maximum (contracts/flex/v1/requests.json `limit`). */
|
|
14
|
+
export const RELATION_OPTIONS_LIMIT = 200;
|
|
12
15
|
|
|
13
16
|
const FormContext = createContext(null);
|
|
14
17
|
|
|
@@ -27,11 +30,15 @@ function Field({ name, children, input }) {
|
|
|
27
30
|
const { form, state } = useForm();
|
|
28
31
|
const i18n = useI18n();
|
|
29
32
|
const errors = state.errors[name] ?? [];
|
|
33
|
+
// A generator default has no value until Flex evaluates it: a new record's field names it instead
|
|
34
|
+
// (ADR-259 decision 9).
|
|
35
|
+
const generator = form.id === undefined ? form.fields.find((f) => f.name === name)?.default?.generator : undefined;
|
|
30
36
|
return h(
|
|
31
37
|
'div',
|
|
32
38
|
{ className: errors.length ? 'field field_with_errors' : 'field' },
|
|
33
39
|
h('label', { htmlFor: `${form.resource.entity}_${name}` }, humanAttributeName(form.resource, name, { i18n })),
|
|
34
40
|
input,
|
|
41
|
+
generator ? h('p', { className: 'field-hint' }, i18n.t(`helpers.resource.default_generator.${generator}`)) : null,
|
|
35
42
|
errors.length ? h('p', { className: 'field-error', role: 'alert' }, errors.join(', ')) : null,
|
|
36
43
|
children,
|
|
37
44
|
);
|
|
@@ -90,13 +97,9 @@ export function RelationField({ name }) {
|
|
|
90
97
|
const i18n = useI18n();
|
|
91
98
|
const { projection } = useSite();
|
|
92
99
|
const field = form.fields.find((f) => f.name === name);
|
|
93
|
-
const [options, setOptions] = useState([]);
|
|
94
|
-
const prefix = field?.relation?.target_prefix;
|
|
95
100
|
const target = field?.relation ? projection?.resources?.[field.relation.target] : undefined;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
api('GET', `${prefix}?limit=200`).then((r) => setOptions(r?.records ?? []), () => setOptions([]));
|
|
99
|
-
}, [prefix]);
|
|
101
|
+
// The related resource's records (one Flex page, its maximum), through the same hook a page lists with.
|
|
102
|
+
const { records: options } = useList(target?.route_prefix ? target : null, { limit: RELATION_OPTIONS_LIMIT });
|
|
100
103
|
const props = inputProps(name, undefined);
|
|
101
104
|
delete props.type;
|
|
102
105
|
return h(Field, {
|
|
@@ -110,12 +113,43 @@ export function RelationField({ name }) {
|
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
/**
|
|
117
|
+
* An enum (ADR-258 decision 10): a select of the live values in declared order, each labelled by
|
|
118
|
+
* human_attribute_name("<field>.<value>"); a blank option when the field is optional, the select prompt
|
|
119
|
+
* when a required field has no value yet. A value outside the live list — removed, or unknown to this
|
|
120
|
+
* projection — stays as a selected, disabled option, so the form never silently changes it.
|
|
121
|
+
*/
|
|
122
|
+
export function EnumField({ name }) {
|
|
123
|
+
const { form, state } = useForm();
|
|
124
|
+
const i18n = useI18n();
|
|
125
|
+
const field = form.fields.find((f) => f.name === name);
|
|
126
|
+
const values = field?.enum?.values ?? [];
|
|
127
|
+
const live = values.filter((v) => !v.removed).map((v) => v.value);
|
|
128
|
+
const current = state.values[name];
|
|
129
|
+
const props = inputProps(name, undefined);
|
|
130
|
+
delete props.type;
|
|
131
|
+
const label = (value) => humanAttributeName(form.resource, `${name}.${value}`, { i18n });
|
|
132
|
+
let lead = null;
|
|
133
|
+
if (current !== '' && current !== undefined && !live.includes(current)) {
|
|
134
|
+
lead = h('option', { value: current, disabled: true }, label(current));
|
|
135
|
+
} else if (!field?.required) {
|
|
136
|
+
lead = h('option', { value: '' }, '');
|
|
137
|
+
} else if (current === '' || current === undefined) {
|
|
138
|
+
lead = h('option', { value: '' }, i18n.t('helpers.select.prompt'));
|
|
139
|
+
}
|
|
140
|
+
return h(Field, {
|
|
141
|
+
name,
|
|
142
|
+
input: h('select', props, lead, live.map((v) => h('option', { key: v, value: v }, label(v)))),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
/** The field component of a Flex type. */
|
|
114
147
|
export function fieldFor(type) {
|
|
115
148
|
return (
|
|
116
149
|
{
|
|
117
150
|
text: TextField, integer: IntegerField, decimal: DecimalField, boolean: BooleanField, date: DateField,
|
|
118
151
|
datetime: DateTimeField, json: JsonField, vector: VectorField, relation: RelationField, file: FileField,
|
|
152
|
+
enum: EnumField,
|
|
119
153
|
}[type] ?? TextField
|
|
120
154
|
);
|
|
121
155
|
}
|
package/src/react/i18n.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// I18nProvider: negotiates the page's locale (spec S4 § Locale negotiation — the `locale` parameter,
|
|
2
2
|
// the #632 preference hook, navigator.languages by RFC 4647 lookup, the default), binds the i18n
|
|
3
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)
|
|
4
|
+
// POST /_air/i18n-missing (bounded per key per page load) — or, with no release, the dev reporter's console
|
|
5
|
+
// warning (a missing key is never silently humanized).
|
|
5
6
|
import { createContext, createElement as h, useContext, useMemo } from 'react';
|
|
6
|
-
import { createI18n, createMissingReporter, negotiateLocale } from '../i18n/index.js';
|
|
7
|
+
import { createDevReporter, createI18n, createMissingReporter, negotiateLocale } from '../i18n/index.js';
|
|
7
8
|
import { setCurrentI18n } from '../i18n/current.js';
|
|
8
9
|
|
|
9
10
|
const I18nContext = createContext(null);
|
|
@@ -23,7 +24,9 @@ export function I18nProvider({ bundle, release, locale, url, languages, preferre
|
|
|
23
24
|
defaultLocale: bundle.default_locale,
|
|
24
25
|
preferred,
|
|
25
26
|
});
|
|
26
|
-
|
|
27
|
+
// A release reports to the product host; with none (a dev server, a preview) the console warns — a
|
|
28
|
+
// missing key or format is never silently humanized (#664 item 28).
|
|
29
|
+
const report = reporter ?? (release ? createMissingReporter({ release, locale: chosen }) : createDevReporter({ locale: chosen }));
|
|
27
30
|
const instance = createI18n({ bundle, locale: chosen, reporter: report });
|
|
28
31
|
setCurrentI18n(instance);
|
|
29
32
|
return instance;
|
package/src/react/index.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
// @aircloudhq/web/react — the React bindings (ADR-222 D3; spec S4 § @aircloudhq/web public modules).
|
|
2
2
|
export { I18nProvider, PageScope, useI18n, useT } from './i18n.js';
|
|
3
3
|
export { Layout, SiteRouter, useSite } from './router.js';
|
|
4
|
+
// The router primitives of the one React Router instance the SiteRouter renders with (#664 item 13): a site
|
|
5
|
+
// imports them from here, never from `react-router` itself — a second copy would break the router context.
|
|
6
|
+
export {
|
|
7
|
+
Link,
|
|
8
|
+
NavLink,
|
|
9
|
+
Navigate,
|
|
10
|
+
Outlet,
|
|
11
|
+
Route,
|
|
12
|
+
Routes,
|
|
13
|
+
useLocation,
|
|
14
|
+
useNavigate,
|
|
15
|
+
useParams,
|
|
16
|
+
useSearchParams,
|
|
17
|
+
} from 'react-router';
|
|
4
18
|
export { ButtonTo, LinkTo } from './links.js';
|
|
5
19
|
export {
|
|
6
20
|
BooleanField,
|
|
7
21
|
DateField,
|
|
8
22
|
DateTimeField,
|
|
9
23
|
DecimalField,
|
|
24
|
+
EnumField,
|
|
10
25
|
ErrorMessages,
|
|
11
26
|
FileField,
|
|
12
27
|
FormWith,
|
|
@@ -18,4 +33,5 @@ export {
|
|
|
18
33
|
fieldFor,
|
|
19
34
|
useForm,
|
|
20
35
|
} from './form.js';
|
|
21
|
-
export { Resource, ResourceForm, ResourceList, ResourceShow, defaultColumns, resourcePage, useDisplay } from './resource.js';
|
|
36
|
+
export { Resource, ResourceForm, ResourceList, ResourceShow, ResourceTabs, defaultColumns, resourcePage, useDisplay } from './resource.js';
|
|
37
|
+
export { hasManyRelations, useList, useRecord } from './data.js';
|
package/src/react/resource.js
CHANGED
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
// display: {[field]: (value, record) => node} how one field shows in list and show views
|
|
9
9
|
// List, Show, Form replace a whole view (props: resource, Default, …)
|
|
10
10
|
// layout: string a layout other than `application`
|
|
11
|
+
// tabs: string[] the show view's has-many tabs, one per relation of
|
|
12
|
+
// another resource that targets this one: "<entity>", or
|
|
13
|
+
// "<entity>.<field>" when that entity relates through
|
|
14
|
+
// more than one field
|
|
11
15
|
//
|
|
12
16
|
// 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
|
-
|
|
17
|
+
// helpers.submit.*); a value renders through `l` and the number helpers of the current locale, a relation
|
|
18
|
+
// by the related record's label field.
|
|
19
|
+
import { createElement as h, useMemo, useState } from 'react';
|
|
15
20
|
import { Route, Routes, useNavigate, useParams } from 'react-router';
|
|
16
21
|
import { createResource, recordLabel } from '../resource.js';
|
|
17
22
|
import { humanAttributeName, modelName } from '../model.js';
|
|
@@ -19,6 +24,7 @@ import { useI18n } from './i18n.js';
|
|
|
19
24
|
import { useSite } from './router.js';
|
|
20
25
|
import { ErrorMessages, FormWith } from './form.js';
|
|
21
26
|
import { ButtonTo, LinkTo } from './links.js';
|
|
27
|
+
import { hasManyRelations, useList, useRecord, useRelationLabels } from './data.js';
|
|
22
28
|
|
|
23
29
|
/** The listColumns default (spec S4 § Constants, Provisional): label field first, up to five. */
|
|
24
30
|
export const LIST_COLUMNS_MAX = 5;
|
|
@@ -29,8 +35,11 @@ export function defaultColumns(resource) {
|
|
|
29
35
|
return [...first, ...names.filter((n) => !first.includes(n))].slice(0, LIST_COLUMNS_MAX);
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A stored value as the current locale renders it; a relation as `relationLabel(field, id)` gives it (the
|
|
40
|
+
* related record's label, useRelationLabels), else its id.
|
|
41
|
+
*/
|
|
42
|
+
export function useDisplay(resource, display = {}, relationLabel = () => undefined) {
|
|
34
43
|
const i18n = useI18n();
|
|
35
44
|
return (field, record) => {
|
|
36
45
|
const value = record?.data?.[field];
|
|
@@ -47,6 +56,10 @@ export function useDisplay(resource, display = {}) {
|
|
|
47
56
|
return i18n.formatNumber('delimited', value);
|
|
48
57
|
case 'boolean':
|
|
49
58
|
return i18n.t(`helpers.resource.boolean.${value ? 'true' : 'false'}`);
|
|
59
|
+
case 'relation':
|
|
60
|
+
return relationLabel(field, String(value)) ?? String(value);
|
|
61
|
+
case 'enum':
|
|
62
|
+
return humanAttributeName(resource, `${field}.${value}`, { i18n });
|
|
50
63
|
case 'json':
|
|
51
64
|
case 'vector':
|
|
52
65
|
return JSON.stringify(value);
|
|
@@ -56,6 +69,21 @@ export function useDisplay(resource, display = {}) {
|
|
|
56
69
|
};
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The list and show views' cell: `show`'s rendering, with an enum value wrapped in a badge (ADR-258
|
|
74
|
+
* decision 10) — class `badge` plus `data-field` and `data-value`, the stable hooks a product's CSS styles
|
|
75
|
+
* each value by. A `display` override renders as given.
|
|
76
|
+
*/
|
|
77
|
+
function badged(resource, display = {}, show) {
|
|
78
|
+
return (field, record) => {
|
|
79
|
+
const node = show(field, record);
|
|
80
|
+
const value = record?.data?.[field];
|
|
81
|
+
if (display[field] || value === undefined || value === null || value === '') return node;
|
|
82
|
+
if (resource.fields.find((f) => f.name === field)?.type !== 'enum') return node;
|
|
83
|
+
return h('span', { className: 'badge', 'data-field': field, 'data-value': String(value) }, node);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
59
87
|
function useResourceOf(name) {
|
|
60
88
|
const { projection } = useSite();
|
|
61
89
|
const r = projection?.resources?.[name];
|
|
@@ -63,30 +91,26 @@ function useResourceOf(name) {
|
|
|
63
91
|
return r;
|
|
64
92
|
}
|
|
65
93
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The default list view. `filter` lists only the records whose fields equal it (a has-many tab); `base`
|
|
96
|
+
* null renders the rows without links to their pages (the related resource's pages are not mounted here).
|
|
97
|
+
*/
|
|
98
|
+
export function ResourceList({ resource, listColumns, display, base = '.', filter }) {
|
|
68
99
|
const i18n = useI18n();
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const [cursor, setCursor] = useState(null);
|
|
72
|
-
const [error, setError] = useState(null);
|
|
73
|
-
const show = useDisplay(resource, display);
|
|
100
|
+
const { records, cursor, error, more } = useList(resource, { filter });
|
|
101
|
+
const show = badged(resource, display, useDisplay(resource, display, useRelationLabels(resource, records)));
|
|
74
102
|
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
103
|
const model = modelName(resource, { i18n });
|
|
104
|
+
const first = (r, c) => {
|
|
105
|
+
const label = show(c, r) || recordLabel(resource, r);
|
|
106
|
+
return base === null ? label : h(LinkTo, { to: `${base}/${encodeURIComponent(r.id)}` }, label);
|
|
107
|
+
};
|
|
84
108
|
return h(
|
|
85
109
|
'section',
|
|
86
110
|
{ className: 'resource-list' },
|
|
87
|
-
h('h1', null, model.human({ count: 2 })),
|
|
111
|
+
base === null ? null : h('h1', null, model.human({ count: 2 })),
|
|
88
112
|
h(ErrorMessages, { error }),
|
|
89
|
-
h('p', null, h(LinkTo, { to: `${base}/new`, className: 'button' }, i18n.t('helpers.resource.new', { model: model.human() }))),
|
|
113
|
+
base === null ? null : h('p', null, h(LinkTo, { to: `${base}/new`, className: 'button' }, i18n.t('helpers.resource.new', { model: model.human() }))),
|
|
90
114
|
h(
|
|
91
115
|
'table',
|
|
92
116
|
null,
|
|
@@ -98,28 +122,71 @@ export function ResourceList({ resource, listColumns, display, base = '.' }) {
|
|
|
98
122
|
h(
|
|
99
123
|
'tr',
|
|
100
124
|
{ 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
|
-
),
|
|
125
|
+
columns.map((c, i) => h('td', { key: c }, i === 0 ? first(r, c) : show(c, r))),
|
|
104
126
|
),
|
|
105
127
|
),
|
|
106
128
|
),
|
|
107
129
|
),
|
|
108
|
-
cursor ? h('p', null, h('button', { type: 'button', onClick: () =>
|
|
130
|
+
cursor ? h('p', null, h('button', { type: 'button', onClick: () => more() }, i18n.t('helpers.resource.more'))) : null,
|
|
109
131
|
);
|
|
110
132
|
}
|
|
111
133
|
|
|
112
|
-
/** The
|
|
113
|
-
export function
|
|
134
|
+
/** The has-many relations a `tabs` option names, resolved against the projection — [{entity, field}]. */
|
|
135
|
+
export function resolveTabs(projection, resource, tabs = []) {
|
|
136
|
+
const all = hasManyRelations(projection, resource.entity);
|
|
137
|
+
return tabs.map((entry) => {
|
|
138
|
+
const [entity, field] = String(entry).split('.');
|
|
139
|
+
const matches = all.filter((r) => r.entity === entity && (field === undefined || r.field === field));
|
|
140
|
+
if (matches.length === 0) throw new Error(`resource "${resource.entity}" has no has-many relation "${entry}"`);
|
|
141
|
+
if (matches.length > 1) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`"${entity}" relates to "${resource.entity}" through more than one field (${matches.map((m) => `${m.entity}.${m.field}`).join(', ')}): name the tab "<entity>.<field>"`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return { ...matches[0], qualified: field !== undefined };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The show view's has-many tabs: a tab per relation, its panel the related records of the shown one. */
|
|
151
|
+
export function ResourceTabs({ resource, id, tabs }) {
|
|
152
|
+
const i18n = useI18n();
|
|
153
|
+
const { projection } = useSite();
|
|
154
|
+
const relations = useMemo(() => resolveTabs(projection, resource, tabs), [projection, resource, tabs]);
|
|
155
|
+
const [active, setActive] = useState(0);
|
|
156
|
+
if (relations.length === 0) return null;
|
|
157
|
+
const label = (rel) => {
|
|
158
|
+
const target = projection.resources[rel.entity];
|
|
159
|
+
const name = modelName(target, { i18n }).human({ count: 2 });
|
|
160
|
+
return rel.qualified ? `${name} (${humanAttributeName(target, rel.field, { i18n })})` : name;
|
|
161
|
+
};
|
|
162
|
+
const current = relations[Math.min(active, relations.length - 1)];
|
|
163
|
+
return h(
|
|
164
|
+
'section',
|
|
165
|
+
{ className: 'resource-tabs' },
|
|
166
|
+
h(
|
|
167
|
+
'nav',
|
|
168
|
+
{ role: 'tablist' },
|
|
169
|
+
relations.map((rel, i) =>
|
|
170
|
+
h('button', { key: `${rel.entity}.${rel.field}`, type: 'button', role: 'tab', 'aria-selected': String(rel === current), onClick: () => setActive(i) }, label(rel)),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
h(
|
|
174
|
+
'div',
|
|
175
|
+
{ role: 'tabpanel' },
|
|
176
|
+
h(ResourceList, { key: `${current.entity}.${current.field}`, resource: projection.resources[current.entity], filter: { [current.field]: id }, base: null }),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The default show view (with `tabs`, the has-many tabs below the record). */
|
|
182
|
+
export function ResourceShow({ resource, display, id, base = '..', tabs }) {
|
|
114
183
|
const i18n = useI18n();
|
|
115
184
|
const navigate = useNavigate();
|
|
116
|
-
const
|
|
117
|
-
const [
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
client.get(id).then(setRecord, setError);
|
|
122
|
-
}, [client, id]);
|
|
185
|
+
const { record, error: readError } = useRecord(resource, id);
|
|
186
|
+
const [actionError, setError] = useState(null);
|
|
187
|
+
const error = actionError ?? readError;
|
|
188
|
+
const records = useMemo(() => (record ? [record] : []), [record]);
|
|
189
|
+
const show = badged(resource, display, useDisplay(resource, display, useRelationLabels(resource, records)));
|
|
123
190
|
return h(
|
|
124
191
|
'section',
|
|
125
192
|
{ className: 'resource-show' },
|
|
@@ -152,21 +219,18 @@ export function ResourceShow({ resource, display, id, base = '..' }) {
|
|
|
152
219
|
),
|
|
153
220
|
h(LinkTo, { to: base }, i18n.t('helpers.resource.back')),
|
|
154
221
|
),
|
|
222
|
+
tabs?.length ? h(ResourceTabs, { resource, id, tabs }) : null,
|
|
155
223
|
);
|
|
156
224
|
}
|
|
157
225
|
|
|
158
|
-
/** The default form view (new when `id` is absent, edit otherwise). */
|
|
226
|
+
/** The default form view (new when `id` is absent, edit otherwise); a relation is a select over its target. */
|
|
159
227
|
export function ResourceForm({ resource, fields, id, base = '..' }) {
|
|
160
228
|
const i18n = useI18n();
|
|
161
229
|
const navigate = useNavigate();
|
|
162
230
|
const client = useMemo(() => createResource(resource), [resource]);
|
|
163
|
-
const
|
|
164
|
-
const [error, setError] = useState(null);
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
if (id !== undefined) client.get(id).then(setRecord, setError);
|
|
167
|
-
}, [client, id]);
|
|
231
|
+
const { record, error } = useRecord(resource, id);
|
|
168
232
|
const model = modelName(resource, { i18n });
|
|
169
|
-
if (
|
|
233
|
+
if (id !== undefined && !record) return h(ErrorMessages, { error });
|
|
170
234
|
return h(
|
|
171
235
|
'section',
|
|
172
236
|
{ className: 'resource-form' },
|
|
@@ -189,7 +253,7 @@ export function ResourceForm({ resource, fields, id, base = '..' }) {
|
|
|
189
253
|
function ShowRoute({ resource, options }) {
|
|
190
254
|
const { id } = useParams();
|
|
191
255
|
const View = options.Show ?? ResourceShow;
|
|
192
|
-
return h(View, { resource, id, display: options.display, Default: ResourceShow, base: '..' });
|
|
256
|
+
return h(View, { resource, id, display: options.display, tabs: options.tabs, Default: ResourceShow, base: '..' });
|
|
193
257
|
}
|
|
194
258
|
|
|
195
259
|
function EditRoute({ resource, options }) {
|
|
@@ -213,7 +277,7 @@ export function Resource({ name, options = {} }) {
|
|
|
213
277
|
);
|
|
214
278
|
}
|
|
215
279
|
|
|
216
|
-
/** export default resourcePage("
|
|
280
|
+
/** export default resourcePage("owner", { listColumns: ["name"], tabs: ["pet"] }) */
|
|
217
281
|
export function resourcePage(name, options = {}) {
|
|
218
282
|
function ResourcePage() {
|
|
219
283
|
return h(Resource, { name, options });
|