@aircloudhq/web 1.0.4 → 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 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.4",
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",
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
+ }
@@ -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 initial = Object.fromEntries(resource.fields.map((f) => [f.name, toInput(f.type, options.record?.data?.[f.name])]));
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
- /** The JSON body of the submit: set fields; on an update a cleared field is null. */
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
  },
@@ -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 { abs, parseDecimal } from '../i18n/decimal.js';
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
- /** number_to_currency(1234567890.50) # => "$1,234,567,890.50" */
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 o = optionsFor('currency', options);
36
- const d = parseDecimal(number);
37
- const rounded = formatRounded(abs(d), o);
38
- const format = d.neg && /[1-9]/.test(rounded) ? o.negative_format : o.format;
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
- const OPTION_KEYS = new Set(['scope', 'default', 'count', 'lazy', 'context', 'locale']);
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
- /** ActiveSupport::NumberHelper::NumberConverter::DEFAULTS. */
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: '$', separator: '.', delimiter: ',', precision: 2, significant: false, strip_insignificant_zeros: false },
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
- return { ...NUMBER_DEFAULTS.format, ...(namespace ? NUMBER_DEFAULTS[namespace] ?? {} : {}), ...base, ...ns, ...options };
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
- const o = numberOptions(bundle, locale, 'currency', options);
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. */
@@ -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
- /** Model.human_attribute_name(:field) */
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;
@@ -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, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
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
- useEffect(() => {
97
- if (!prefix) return;
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
- const report = reporter ?? (release ? createMissingReporter({ release, locale: chosen }) : undefined);
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;
@@ -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';
@@ -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
- import { createElement as h, useEffect, useMemo, useState } from 'react';
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
- /** A stored value as the current locale renders it. */
33
- export function useDisplay(resource, display = {}) {
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
- /** The default list view. */
67
- export function ResourceList({ resource, listColumns, display, base = '.' }) {
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 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);
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: () => load(cursor) }, i18n.t('helpers.resource.more'))) : null,
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 default show view. */
113
- export function ResourceShow({ resource, display, id, base = '..' }) {
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 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]);
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 [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]);
231
+ const { record, error } = useRecord(resource, id);
168
232
  const model = modelName(resource, { i18n });
169
- if (record === undefined) return h(ErrorMessages, { error });
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("pet", { listColumns: ["name", "species"] }) */
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 });