@aircloudhq/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +52 -0
- package/src/api.js +48 -0
- package/src/function.js +73 -0
- package/src/helpers/date.js +66 -0
- package/src/helpers/form.js +203 -0
- package/src/helpers/navigation.js +70 -0
- package/src/helpers/number.js +131 -0
- package/src/helpers/sanitize-allowlist.js +10 -0
- package/src/helpers/sanitize.js +73 -0
- package/src/helpers/tag.js +37 -0
- package/src/helpers/text.js +143 -0
- package/src/helpers/translation.js +23 -0
- package/src/helpers/url.js +86 -0
- package/src/i18n/civil.js +43 -0
- package/src/i18n/current.js +25 -0
- package/src/i18n/decimal.js +109 -0
- package/src/i18n/index.js +373 -0
- package/src/i18n/missing.js +35 -0
- package/src/i18n/negotiate.js +67 -0
- package/src/i18n/plural.js +41 -0
- package/src/i18n/strftime.js +76 -0
- package/src/i18n/zone.js +39 -0
- package/src/index.js +16 -0
- package/src/model.js +38 -0
- package/src/react/form.js +167 -0
- package/src/react/i18n.js +52 -0
- package/src/react/index.js +21 -0
- package/src/react/links.js +56 -0
- package/src/react/resource.js +225 -0
- package/src/react/router.js +44 -0
- package/src/resource.js +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Air-Cloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aircloudhq/web",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The Air-Cloud site application package (ADR-222): the Rails Action View helpers, the i18n runtime of the locale bundle (ADR-223), resource pages over the resource projection, and React bindings.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.22.0"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./helpers/number": "./src/helpers/number.js",
|
|
13
|
+
"./helpers/date": "./src/helpers/date.js",
|
|
14
|
+
"./helpers/text": "./src/helpers/text.js",
|
|
15
|
+
"./helpers/translation": "./src/helpers/translation.js",
|
|
16
|
+
"./helpers/url": "./src/helpers/url.js",
|
|
17
|
+
"./helpers/navigation": "./src/helpers/navigation.js",
|
|
18
|
+
"./helpers/sanitize": "./src/helpers/sanitize.js",
|
|
19
|
+
"./helpers/tag": "./src/helpers/tag.js",
|
|
20
|
+
"./helpers/form": "./src/helpers/form.js",
|
|
21
|
+
"./model": "./src/model.js",
|
|
22
|
+
"./resource": "./src/resource.js",
|
|
23
|
+
"./api": "./src/api.js",
|
|
24
|
+
"./i18n": "./src/i18n/index.js",
|
|
25
|
+
"./function": "./src/function.js",
|
|
26
|
+
"./react": "./src/react/index.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src/"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"react-router": "8.4.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=19.2.7",
|
|
38
|
+
"react-dom": ">=19.2.7"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"jsdom": "30.1.1",
|
|
42
|
+
"react": "19.3.0",
|
|
43
|
+
"react-dom": "19.3.0",
|
|
44
|
+
"vitest": "5.0.2"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The same-origin API client over the platform error envelope
|
|
2
|
+
// {"error":{"code","message","correlation_id","details"}} — the seam #246's typed SDK replaces. Every
|
|
3
|
+
// call sends `Accept-Language: <current locale>` (spec S4 § Locale negotiation: the function runtime
|
|
4
|
+
// negotiates from it), and the release pin cookie rides with it (same origin).
|
|
5
|
+
import { currentI18n } from './i18n/current.js';
|
|
6
|
+
|
|
7
|
+
export class ApiError extends Error {
|
|
8
|
+
constructor(status, code, message, details = {}, correlationId = '') {
|
|
9
|
+
super(message || code);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details ?? {};
|
|
14
|
+
this.correlationId = correlationId ?? '';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* api("GET", "/api/pets") — resolves to the parsed JSON body (null for 204); rejects with an ApiError
|
|
20
|
+
* carrying the envelope's code, message, details and correlation id.
|
|
21
|
+
* @param {{locale?: string, fetch?: typeof fetch, headers?: Record<string,string>}} [options]
|
|
22
|
+
*/
|
|
23
|
+
export async function api(method, path, body, options = {}) {
|
|
24
|
+
const locale = options.locale ?? currentI18n()?.locale;
|
|
25
|
+
const headers = { accept: 'application/json', ...(options.headers ?? {}) };
|
|
26
|
+
if (locale) headers['accept-language'] = locale;
|
|
27
|
+
const init = { method, headers };
|
|
28
|
+
if (body !== undefined) {
|
|
29
|
+
init.body = JSON.stringify(body);
|
|
30
|
+
headers['content-type'] = 'application/json';
|
|
31
|
+
}
|
|
32
|
+
const res = await (options.fetch ?? fetch)(path, init);
|
|
33
|
+
if (res.status === 204) return null;
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let value = null;
|
|
36
|
+
if (text !== '') {
|
|
37
|
+
try {
|
|
38
|
+
value = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
if (res.ok) throw new ApiError(res.status, 'invalid_response', 'the response is not JSON');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const err = (value && value.error) || {};
|
|
45
|
+
throw new ApiError(res.status, err.code || `http_${res.status}`, err.message, err.details, err.correlation_id);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
package/src/function.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @aircloudhq/web/function — the i18n binding of a JavaScript function (ADR-223 D3, D7; spec S4
|
|
2
|
+
// § Locale negotiation, functions; § Run time). A function does not carry the locale bundle: the
|
|
3
|
+
// functions host serves the bundle of the release the invocation resolved to through the
|
|
4
|
+
// `aircloud:functions/i18n` capability, and this module turns it into the same `t` / `l` / number /
|
|
5
|
+
// `pluralize` runtime the site uses (./i18n), so a function answers exactly as its site renders.
|
|
6
|
+
//
|
|
7
|
+
// import { i18nFor } from '@aircloudhq/web/function';
|
|
8
|
+
// const i18n = await i18nFor(request, { function: 'greet' });
|
|
9
|
+
// i18n.t('.title'); // functions.greet.title in the negotiated locale
|
|
10
|
+
//
|
|
11
|
+
// Negotiation, in S4's order: (1) the end-user and organisation preference — reserved for #632;
|
|
12
|
+
// (2) the request's Accept-Language, RFC 4647 lookup; (3) for a trigger without a request (an
|
|
13
|
+
// `x-air-trigger: event` delivery), the `locale` key of the event payload; (4) default_locale. The
|
|
14
|
+
// URL's `locale` parameter is a site concern and is never read here.
|
|
15
|
+
//
|
|
16
|
+
// A bundle the host cannot resolve is not defaulted: `locales()` / `bundle()` throw the capability's
|
|
17
|
+
// ComponentError (payload.code `functions.locale_bundle_unresolvable`) and it reaches the function.
|
|
18
|
+
// A key no locale resolves renders humanized and is reported through the capability, which alarms
|
|
19
|
+
// once per key per release.
|
|
20
|
+
import { bundle, keyMissing, locales } from 'aircloud:functions/i18n@1.0.0';
|
|
21
|
+
import { acceptLanguage, createI18n, lookupLocale } from './i18n/index.js';
|
|
22
|
+
|
|
23
|
+
function canonical(available, tag) {
|
|
24
|
+
if (typeof tag !== 'string') return undefined;
|
|
25
|
+
const t = tag.toLowerCase();
|
|
26
|
+
return available.find((a) => a.toLowerCase() === t);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The locale of a function invocation.
|
|
31
|
+
* @param {{default_locale: string, available_locales: string[]}} header the bundle header
|
|
32
|
+
* @param {{acceptLanguage?: string|null, trigger?: string|null, payload?: any}} input
|
|
33
|
+
*/
|
|
34
|
+
export function negotiateFunctionLocale(header, { acceptLanguage: al, trigger, payload } = {}) {
|
|
35
|
+
const available = header.available_locales;
|
|
36
|
+
const fromHeader = al ? lookupLocale(acceptLanguage(al), available) : undefined;
|
|
37
|
+
if (fromHeader) return fromHeader;
|
|
38
|
+
if (trigger === 'event') {
|
|
39
|
+
const fromEvent = canonical(available, payload?.data?.locale);
|
|
40
|
+
if (fromEvent) return fromEvent;
|
|
41
|
+
}
|
|
42
|
+
return header.default_locale;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The i18n runtime of one invocation: the negotiated locale and the release's bundle for it.
|
|
47
|
+
* @param {Request} request the invocation's request (for a trigger, its delivery)
|
|
48
|
+
* @param {{function?: string}} [options] the function's name, the scope of its lazy keys
|
|
49
|
+
*/
|
|
50
|
+
export async function i18nFor(request, { function: name } = {}) {
|
|
51
|
+
const header = JSON.parse(locales());
|
|
52
|
+
const trigger = request.headers.get('x-air-trigger');
|
|
53
|
+
let payload;
|
|
54
|
+
if (trigger === 'event') {
|
|
55
|
+
try {
|
|
56
|
+
payload = await request.clone().json();
|
|
57
|
+
} catch {
|
|
58
|
+
payload = undefined; // a delivery that is not JSON carries no locale
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const locale = negotiateFunctionLocale(header, { acceptLanguage: request.headers.get('accept-language'), trigger, payload });
|
|
62
|
+
const reported = new Set();
|
|
63
|
+
return createI18n({
|
|
64
|
+
bundle: JSON.parse(bundle(locale)),
|
|
65
|
+
locale,
|
|
66
|
+
lazy: name ? { function: name } : undefined,
|
|
67
|
+
reporter: (key) => {
|
|
68
|
+
if (reported.has(key)) return;
|
|
69
|
+
reported.add(key);
|
|
70
|
+
keyMissing(locale, key);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ActionView::Helpers::DateHelper — the distance-in-words helpers, over the locale's
|
|
2
|
+
// `datetime.distance_in_words.*` keys (pluralized by count). Instants only: the arithmetic uses
|
|
3
|
+
// epoch milliseconds and UTC calendar fields, never the host's time zone.
|
|
4
|
+
import { requireI18n } from '../i18n/current.js';
|
|
5
|
+
|
|
6
|
+
const MINUTES_IN_YEAR = 525600;
|
|
7
|
+
const MINUTES_IN_QUARTER_YEAR = 131400;
|
|
8
|
+
const MINUTES_IN_THREE_QUARTERS_YEAR = 394200;
|
|
9
|
+
|
|
10
|
+
const ms = (t) => (t instanceof Date ? t.getTime() : typeof t === 'number' ? t : Date.parse(t));
|
|
11
|
+
const isLeap = (y) => y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
|
|
12
|
+
|
|
13
|
+
/** distance_of_time_in_words(from_time, from_time + 50.minutes) # => "about 1 hour" */
|
|
14
|
+
export function distanceOfTimeInWords(fromTime, toTime = 0, options = {}) {
|
|
15
|
+
const i18n = requireI18n(options, 'distanceOfTimeInWords');
|
|
16
|
+
const scope = options.scope ?? 'datetime.distance_in_words';
|
|
17
|
+
let from = ms(fromTime);
|
|
18
|
+
let to = ms(toTime);
|
|
19
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) throw new RangeError('distanceOfTimeInWords: not a time');
|
|
20
|
+
if (from > to) [from, to] = [to, from];
|
|
21
|
+
const tr = (key, count) => i18n.t(key, { scope, count });
|
|
22
|
+
const seconds = Math.round((to - from) / 1000);
|
|
23
|
+
const minutes = Math.round((to - from) / 60000);
|
|
24
|
+
if (minutes <= 1) {
|
|
25
|
+
if (!options.includeSeconds && !options.include_seconds) {
|
|
26
|
+
return minutes === 0 ? tr('less_than_x_minutes', 1) : tr('x_minutes', minutes);
|
|
27
|
+
}
|
|
28
|
+
if (seconds <= 4) return tr('less_than_x_seconds', 5);
|
|
29
|
+
if (seconds <= 9) return tr('less_than_x_seconds', 10);
|
|
30
|
+
if (seconds <= 19) return tr('less_than_x_seconds', 20);
|
|
31
|
+
if (seconds <= 39) return tr('half_a_minute');
|
|
32
|
+
if (seconds <= 59) return tr('less_than_x_minutes', 1);
|
|
33
|
+
return tr('x_minutes', 1);
|
|
34
|
+
}
|
|
35
|
+
if (minutes < 45) return tr('x_minutes', minutes);
|
|
36
|
+
if (minutes < 90) return tr('about_x_hours', 1);
|
|
37
|
+
if (minutes < 1440) return tr('about_x_hours', Math.round(minutes / 60));
|
|
38
|
+
if (minutes < 2520) return tr('x_days', 1);
|
|
39
|
+
if (minutes < 43200) return tr('x_days', Math.round(minutes / 1440));
|
|
40
|
+
if (minutes < 86400) return tr('about_x_months', Math.round(minutes / 43200));
|
|
41
|
+
if (minutes < 525600) return tr('x_months', Math.round(minutes / 43200));
|
|
42
|
+
const f = new Date(from);
|
|
43
|
+
const t = new Date(to);
|
|
44
|
+
let fromYear = f.getUTCFullYear();
|
|
45
|
+
if (f.getUTCMonth() + 1 >= 3) fromYear += 1;
|
|
46
|
+
let toYear = t.getUTCFullYear();
|
|
47
|
+
if (t.getUTCMonth() + 1 < 3) toYear -= 1;
|
|
48
|
+
let leapYears = 0;
|
|
49
|
+
for (let y = fromYear; y <= toYear; y += 1) if (isLeap(y)) leapYears += 1;
|
|
50
|
+
const withOffset = minutes - leapYears * 1440;
|
|
51
|
+
const remainder = withOffset % MINUTES_IN_YEAR;
|
|
52
|
+
const years = Math.floor(withOffset / MINUTES_IN_YEAR);
|
|
53
|
+
if (remainder < MINUTES_IN_QUARTER_YEAR) return tr('about_x_years', years);
|
|
54
|
+
if (remainder < MINUTES_IN_THREE_QUARTERS_YEAR) return tr('over_x_years', years);
|
|
55
|
+
return tr('almost_x_years', years + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** time_ago_in_words(3.minutes.from_now) # => "3 minutes" */
|
|
59
|
+
export function timeAgoInWords(fromTime, options = {}) {
|
|
60
|
+
return distanceOfTimeInWords(fromTime, options.now ?? Date.now(), options);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** distance_of_time_in_words_to_now — Rails' alias of time_ago_in_words. */
|
|
64
|
+
export function distanceOfTimeInWordsToNow(fromTime, options = {}) {
|
|
65
|
+
return timeAgoInWords(fromTime, options);
|
|
66
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The framework-neutral form model (FormHelper + ActiveModel::Validations + ActiveModel::Dirty): field
|
|
2
|
+
// state from the resource projection, client-side validation mirroring the Flex field policies (the
|
|
3
|
+
// server stays authoritative), dirty tracking, and the submit state. React binds it through
|
|
4
|
+
// useSyncExternalStore (`FormWith`).
|
|
5
|
+
//
|
|
6
|
+
// Validation messages resolve as ActiveModel::Error#generate_message does:
|
|
7
|
+
// activemodel.errors.models.<entity>.attributes.<field>.<kind>, activemodel.errors.models.<entity>.<kind>,
|
|
8
|
+
// activemodel.errors.messages.<kind>, errors.attributes.<field>.<kind>, errors.messages.<kind>
|
|
9
|
+
// and a full message is `errors.format` ("%{attribute} %{message}") over the human attribute name.
|
|
10
|
+
import { i18nFor } from '../i18n/current.js';
|
|
11
|
+
import { humanAttributeName, modelName } from '../model.js';
|
|
12
|
+
|
|
13
|
+
/** The value a form input shows for a stored value of a Flex type. */
|
|
14
|
+
export function toInput(type, value) {
|
|
15
|
+
if (value === undefined || value === null) return type === 'boolean' ? false : '';
|
|
16
|
+
switch (type) {
|
|
17
|
+
case 'boolean':
|
|
18
|
+
return Boolean(value);
|
|
19
|
+
case 'datetime':
|
|
20
|
+
return new Date(value).toISOString().slice(0, 16);
|
|
21
|
+
case 'json':
|
|
22
|
+
case 'vector':
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
default:
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The JSON value of a form input for a Flex type; undefined means "not set". Throws on a malformed json. */
|
|
30
|
+
export function toValue(type, raw) {
|
|
31
|
+
if (type === 'boolean') return Boolean(raw);
|
|
32
|
+
if (raw === '' || raw === undefined || raw === null) return undefined;
|
|
33
|
+
switch (type) {
|
|
34
|
+
case 'integer':
|
|
35
|
+
return Number(raw);
|
|
36
|
+
case 'datetime':
|
|
37
|
+
return new Date(`${raw}Z`).toISOString(); // the input edits UTC
|
|
38
|
+
case 'json':
|
|
39
|
+
case 'vector':
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
default:
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The ActiveModel validation kind a raw input violates for its field, or null. */
|
|
47
|
+
export function validationKind(field, raw) {
|
|
48
|
+
const empty = raw === '' || raw === undefined || raw === null || (field.type === 'boolean' ? false : false);
|
|
49
|
+
if (empty) return field.required && field.type !== 'boolean' ? 'blank' : null;
|
|
50
|
+
switch (field.type) {
|
|
51
|
+
case 'integer':
|
|
52
|
+
return /^-?\d+$/.test(String(raw).trim()) ? null : 'not_an_integer';
|
|
53
|
+
case 'decimal':
|
|
54
|
+
return /^-?\d+(\.\d+)?$/.test(String(raw).trim()) ? null : 'not_a_number';
|
|
55
|
+
case 'date':
|
|
56
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(String(raw)) ? null : 'invalid';
|
|
57
|
+
case 'datetime':
|
|
58
|
+
return Number.isFinite(Date.parse(`${raw}Z`)) ? null : 'invalid';
|
|
59
|
+
case 'json':
|
|
60
|
+
case 'vector':
|
|
61
|
+
try {
|
|
62
|
+
JSON.parse(raw);
|
|
63
|
+
return null;
|
|
64
|
+
} catch {
|
|
65
|
+
return 'invalid';
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ActiveModel::Error#generate_message for (resource, field, kind). */
|
|
73
|
+
export function errorMessage(resource, field, kind, options = {}) {
|
|
74
|
+
const i18n = i18nFor(options);
|
|
75
|
+
if (!i18n) return kind;
|
|
76
|
+
const e = resource.entity;
|
|
77
|
+
const keys = [
|
|
78
|
+
`activemodel.errors.models.${e}.attributes.${field}.${kind}`,
|
|
79
|
+
`activemodel.errors.models.${e}.${kind}`,
|
|
80
|
+
`activemodel.errors.messages.${kind}`,
|
|
81
|
+
`errors.attributes.${field}.${kind}`,
|
|
82
|
+
`errors.messages.${kind}`,
|
|
83
|
+
];
|
|
84
|
+
const [first, ...rest] = keys;
|
|
85
|
+
return i18n.t(first, {
|
|
86
|
+
default: rest.map((key) => ({ key })),
|
|
87
|
+
model: modelName(resource, { i18n }).human(),
|
|
88
|
+
attribute: humanAttributeName(resource, field, { i18n }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** ActiveModel::Errors#full_message. */
|
|
93
|
+
export function fullMessage(resource, field, message, options = {}) {
|
|
94
|
+
const i18n = i18nFor(options);
|
|
95
|
+
const attribute = humanAttributeName(resource, field, { i18n });
|
|
96
|
+
if (!i18n) return `${attribute} ${message}`;
|
|
97
|
+
return i18n.t('errors.format', { default: [{ key: 'activemodel.errors.format' }], attribute, message });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* createForm(resource, {record, i18n}) — the form model of one resource: a new record, or the edit of
|
|
102
|
+
* `record` ({id, data}).
|
|
103
|
+
*/
|
|
104
|
+
export function createForm(resource, options = {}) {
|
|
105
|
+
const i18n = () => i18nFor(options);
|
|
106
|
+
const initial = Object.fromEntries(resource.fields.map((f) => [f.name, toInput(f.type, options.record?.data?.[f.name])]));
|
|
107
|
+
let state = {
|
|
108
|
+
values: { ...initial },
|
|
109
|
+
initial: { ...initial },
|
|
110
|
+
errors: {},
|
|
111
|
+
base: [],
|
|
112
|
+
status: 'idle',
|
|
113
|
+
error: null,
|
|
114
|
+
touched: {},
|
|
115
|
+
};
|
|
116
|
+
const listeners = new Set();
|
|
117
|
+
const emit = () => listeners.forEach((l) => l());
|
|
118
|
+
const setState = (patch) => {
|
|
119
|
+
state = { ...state, ...patch };
|
|
120
|
+
emit();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const model = {
|
|
124
|
+
resource,
|
|
125
|
+
id: options.record?.id,
|
|
126
|
+
fields: resource.fields,
|
|
127
|
+
subscribe(listener) {
|
|
128
|
+
listeners.add(listener);
|
|
129
|
+
return () => listeners.delete(listener);
|
|
130
|
+
},
|
|
131
|
+
getState: () => state,
|
|
132
|
+
set(name, value) {
|
|
133
|
+
setState({ values: { ...state.values, [name]: value }, touched: { ...state.touched, [name]: true } });
|
|
134
|
+
},
|
|
135
|
+
/** Reload the edited record (and make its values the clean baseline). */
|
|
136
|
+
load(record) {
|
|
137
|
+
const values = Object.fromEntries(resource.fields.map((f) => [f.name, toInput(f.type, record?.data?.[f.name])]));
|
|
138
|
+
model.id = record?.id;
|
|
139
|
+
setState({ values, initial: { ...values }, errors: {}, base: [], touched: {} });
|
|
140
|
+
},
|
|
141
|
+
/** The fields whose value differs from the baseline (ActiveModel::Dirty#changed). */
|
|
142
|
+
changed: () => resource.fields.map((f) => f.name).filter((n) => state.values[n] !== state.initial[n]),
|
|
143
|
+
dirty: () => model.changed().length > 0,
|
|
144
|
+
/** {field: [before, after]} (ActiveModel::Dirty#changes). */
|
|
145
|
+
changes: () => Object.fromEntries(model.changed().map((n) => [n, [state.initial[n], state.values[n]]])),
|
|
146
|
+
/** Validate every field; returns true when valid. */
|
|
147
|
+
validate() {
|
|
148
|
+
const errors = {};
|
|
149
|
+
for (const f of resource.fields) {
|
|
150
|
+
const kind = validationKind(f, state.values[f.name]);
|
|
151
|
+
if (kind) errors[f.name] = [errorMessage(resource, f.name, kind, { i18n: i18n() })];
|
|
152
|
+
}
|
|
153
|
+
setState({ errors });
|
|
154
|
+
return Object.keys(errors).length === 0;
|
|
155
|
+
},
|
|
156
|
+
/** The JSON body of the submit: set fields; on an update a cleared field is null. */
|
|
157
|
+
payload() {
|
|
158
|
+
const data = {};
|
|
159
|
+
for (const f of resource.fields) {
|
|
160
|
+
const v = toValue(f.type, state.values[f.name]);
|
|
161
|
+
if (v !== undefined) data[f.name] = v;
|
|
162
|
+
else if (model.id !== undefined) data[f.name] = null;
|
|
163
|
+
}
|
|
164
|
+
return data;
|
|
165
|
+
},
|
|
166
|
+
/** Map a platform error onto the form: base error, and per-field marks for flex.validation. */
|
|
167
|
+
applyError(error) {
|
|
168
|
+
const errors = {};
|
|
169
|
+
const fields = error?.details?.fields;
|
|
170
|
+
if (fields && typeof fields === 'object') {
|
|
171
|
+
for (const name of Object.keys(fields)) {
|
|
172
|
+
if (resource.fields.some((f) => f.name === name)) errors[name] = [errorMessage(resource, name, 'invalid', { i18n: i18n() })];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
setState({ errors, base: error ? [error] : [], status: 'failed', error });
|
|
176
|
+
},
|
|
177
|
+
/** Validate, then run `send(payload, form)`; the submit state follows the promise. */
|
|
178
|
+
async submit(send) {
|
|
179
|
+
if (!model.validate()) {
|
|
180
|
+
setState({ status: 'failed', error: null });
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
setState({ status: 'submitting', error: null, base: [] });
|
|
184
|
+
try {
|
|
185
|
+
const result = await send(model.payload(), model);
|
|
186
|
+
setState({ status: 'succeeded', initial: { ...state.values } });
|
|
187
|
+
return result;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
model.applyError(e);
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
return model;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Rails form_with's submit label: helpers.submit.create / update with %{model}. */
|
|
198
|
+
export function submitLabel(resource, persisted, options = {}) {
|
|
199
|
+
const i18n = i18nFor(options);
|
|
200
|
+
const model = modelName(resource, { i18n }).human();
|
|
201
|
+
if (!i18n) return model;
|
|
202
|
+
return i18n.t(persisted ? 'helpers.submit.update' : 'helpers.submit.create', { model });
|
|
203
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ActionView::Helpers::NavigationHelper at the census commit (link_to, button_to, current_page?,
|
|
2
|
+
// link_to_unless_current, link_to_unless, link_to_if). The tag helpers return markup; every URL goes
|
|
3
|
+
// through urlFor, so the current page's `locale` parameter is carried forward (ADR-223 D7). There is
|
|
4
|
+
// no authenticity token: a product site is a static SPA and its writes go to functions.
|
|
5
|
+
import { escapeHtml } from '../i18n/index.js';
|
|
6
|
+
import { htmlAttributes, urlFor } from './url.js';
|
|
7
|
+
|
|
8
|
+
const METHOD_VERBS = ['patch', 'put', 'delete'];
|
|
9
|
+
|
|
10
|
+
/** link_to "Profile", "/profiles/1" # => <a href="/profiles/1">Profile</a> */
|
|
11
|
+
export function linkTo(name, url, htmlOptions = {}) {
|
|
12
|
+
const { currentUrl, ...html } = htmlOptions;
|
|
13
|
+
const href = urlFor(url, { currentUrl });
|
|
14
|
+
return `<a${htmlAttributes({ ...html, href })}>${escapeHtml(name ?? href)}</a>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* button_to "New", "/articles/new", method: :get
|
|
19
|
+
* # => <form class="button_to" method="get" action="/articles/new"><button type="submit">New</button></form>
|
|
20
|
+
*/
|
|
21
|
+
export function buttonTo(name, url, htmlOptions = {}) {
|
|
22
|
+
const { method: m, params, form, formClass, form_class, currentUrl, ...html } = htmlOptions;
|
|
23
|
+
const method = String(m ?? 'post').toLowerCase();
|
|
24
|
+
const action = urlFor(url, { currentUrl });
|
|
25
|
+
const formMethod = method === 'get' ? 'get' : 'post';
|
|
26
|
+
const formAttrs = { class: form?.class ?? formClass ?? form_class ?? 'button_to', ...(form ?? {}), method: formMethod, action };
|
|
27
|
+
const methodTag = METHOD_VERBS.includes(method)
|
|
28
|
+
? `<input type="hidden" name="_method" value="${method}" autocomplete="off" />`
|
|
29
|
+
: '';
|
|
30
|
+
const button = `<button${htmlAttributes({ ...html, type: 'submit' })}>${escapeHtml(name ?? action)}</button>`;
|
|
31
|
+
const hidden = Object.entries(params ?? {})
|
|
32
|
+
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" autocomplete="off" />`)
|
|
33
|
+
.join('');
|
|
34
|
+
return `<form${htmlAttributes(formAttrs)}>${methodTag}${button}${hidden}</form>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const trimSlash = (s) => (s.length > 1 ? s.replace(/\/+$/, '') : s);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* current_page?("/shop/checkout") — the URL names the current page: its path (and, when the URL has
|
|
41
|
+
* a query or `checkParameters` is set, its query) equals the current request's.
|
|
42
|
+
*/
|
|
43
|
+
export function currentPage(url, options = {}) {
|
|
44
|
+
const here = options.currentUrl
|
|
45
|
+
? new URL(String(options.currentUrl), 'http://x')
|
|
46
|
+
: typeof location !== 'undefined'
|
|
47
|
+
? new URL(location.href)
|
|
48
|
+
: undefined;
|
|
49
|
+
if (!here) throw new Error('currentPage: no current URL');
|
|
50
|
+
const target = new URL(urlFor(url, { currentUrl: here.href, carryLocale: false }), here.href);
|
|
51
|
+
const withQuery = String(typeof url === 'string' ? url : url?.path ?? '').includes('?') || options.checkParameters;
|
|
52
|
+
const a = decodeURIComponent(trimSlash(target.pathname) + (withQuery ? target.search : ''));
|
|
53
|
+
const b = decodeURIComponent(trimSlash(here.pathname) + (withQuery ? here.search : ''));
|
|
54
|
+
return a === b;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** link_to_unless_current("Home", "/") — the name alone on the current page, else the link. */
|
|
58
|
+
export function linkToUnlessCurrent(name, url, htmlOptions = {}) {
|
|
59
|
+
return linkToUnless(currentPage(url, { currentUrl: htmlOptions.currentUrl }), name, url, htmlOptions);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** link_to_unless(condition, "Reply", "/reply") — the name alone when the condition holds. */
|
|
63
|
+
export function linkToUnless(condition, name, url, htmlOptions = {}) {
|
|
64
|
+
return condition ? escapeHtml(name) : linkTo(name, url, htmlOptions);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** link_to_if(condition, "Login", "/login") — the link when the condition holds, else the name. */
|
|
68
|
+
export function linkToIf(condition, name, url, htmlOptions = {}) {
|
|
69
|
+
return linkToUnless(!condition, name, url, htmlOptions);
|
|
70
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ActionView::Helpers::NumberHelper (ActiveSupport::NumberHelper) at the census commit, over the
|
|
2
|
+
// locale bundle's `number.*` keys: the same option names (camelCase accepted as well), the same
|
|
3
|
+
// defaults, BigDecimal half-up rounding. Without a bound i18n instance the Rails defaults apply.
|
|
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';
|
|
7
|
+
|
|
8
|
+
const SNAKE = {
|
|
9
|
+
stripInsignificantZeros: 'strip_insignificant_zeros',
|
|
10
|
+
negativeFormat: 'negative_format',
|
|
11
|
+
areaCode: 'area_code',
|
|
12
|
+
countryCode: 'country_code',
|
|
13
|
+
delimiterPattern: 'delimiter_pattern',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function snake(options = {}) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [k, v] of Object.entries(options)) if (k !== 'i18n') out[SNAKE[k] ?? k] = v;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function optionsFor(namespace, options) {
|
|
23
|
+
const o = snake(options);
|
|
24
|
+
const i = i18nFor(options);
|
|
25
|
+
if (i) return i.numberOptions(namespace, o);
|
|
26
|
+
return { ...NUMBER_DEFAULTS.format, ...(namespace ? NUMBER_DEFAULTS[namespace] ?? {} : {}), ...o };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lookup(options, key) {
|
|
30
|
+
return i18nFor(options)?.lookup(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** number_to_currency(1234567890.50) # => "$1,234,567,890.50" */
|
|
34
|
+
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);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** number_with_delimiter(12345678) # => "12,345,678" */
|
|
43
|
+
export function numberWithDelimiter(number, options = {}) {
|
|
44
|
+
return formatDelimited(number, optionsFor(null, options));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** number_with_precision(111.2345) # => "111.235" */
|
|
48
|
+
export function numberWithPrecision(number, options = {}) {
|
|
49
|
+
return formatRounded(number, optionsFor('precision', options));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** number_to_percentage(100) # => "100.000%" */
|
|
53
|
+
export function numberToPercentage(number, options = {}) {
|
|
54
|
+
const o = optionsFor('percentage', options);
|
|
55
|
+
return o.format.replace('%n', formatRounded(number, o));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DECIMAL_UNITS = { 0: 'unit', 1: 'ten', 2: 'hundred', 3: 'thousand', 6: 'million', 9: 'billion', 12: 'trillion', 15: 'quadrillion', '-1': 'deci', '-2': 'centi', '-3': 'mili', '-6': 'micro', '-9': 'nano', '-12': 'pico', '-15': 'femto' };
|
|
59
|
+
|
|
60
|
+
function exponentOf(d) {
|
|
61
|
+
if (d.int !== '0') return d.int.length - 1;
|
|
62
|
+
const lead = d.frac.match(/^0*/)[0].length;
|
|
63
|
+
return lead === d.frac.length ? 0 : -(lead + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** number_to_human(1234567) # => "1.23 Million" */
|
|
67
|
+
export function numberToHuman(number, options = {}) {
|
|
68
|
+
const o = optionsFor('human', options);
|
|
69
|
+
const units = { ...NUMBER_DEFAULTS.human.units, ...(lookup(options, 'number.human.decimal_units.units') ?? {}), ...(typeof o.units === 'object' ? o.units : {}) };
|
|
70
|
+
const format = o.format && o.format.includes('%u') ? o.format : lookup(options, 'number.human.decimal_units.format') ?? '%n %u';
|
|
71
|
+
const d = parseDecimal(number);
|
|
72
|
+
// Round first (Rails rounds, then takes the exponent of the rounded number).
|
|
73
|
+
const rounded = parseDecimal(formatRounded(d, { ...o, delimiter: '', separator: '.', strip_insignificant_zeros: false }));
|
|
74
|
+
const exponent = exponentOf(rounded);
|
|
75
|
+
const available = Object.entries(DECIMAL_UNITS)
|
|
76
|
+
.filter(([, name]) => units[name] !== undefined)
|
|
77
|
+
.map(([e]) => Number(e))
|
|
78
|
+
.sort((a, b) => b - a);
|
|
79
|
+
const unitExp = available.find((e) => e <= exponent) ?? available[available.length - 1] ?? 0;
|
|
80
|
+
const scaled = shift(d, -unitExp);
|
|
81
|
+
const value = formatRounded(scaled, o);
|
|
82
|
+
const unit = units[DECIMAL_UNITS[unitExp]];
|
|
83
|
+
const unitText = typeof unit === 'object' ? (Number(value.replace(o.separator, '.')) === 1 ? unit.one : unit.other) : unit;
|
|
84
|
+
return format.replace('%n', value).replace('%u', unitText ?? '').trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shift(d, places) {
|
|
88
|
+
const digits = d.int + d.frac;
|
|
89
|
+
const point = d.int.length + places;
|
|
90
|
+
const padded = point < 0 ? '0'.repeat(-point) + digits : point > digits.length ? digits + '0'.repeat(point - digits.length) : digits;
|
|
91
|
+
const p = Math.max(point, 0);
|
|
92
|
+
return parseDecimal(`${d.neg ? '-' : ''}${padded.slice(0, p) || '0'}.${padded.slice(p)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const STORAGE = ['byte', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb'];
|
|
96
|
+
|
|
97
|
+
/** number_to_human_size(1234567) # => "1.18 MB" */
|
|
98
|
+
export function numberToHumanSize(number, options = {}) {
|
|
99
|
+
const o = optionsFor('human', options);
|
|
100
|
+
const units = { ...NUMBER_DEFAULTS.storage_units.units, ...(lookup(options, 'number.human.storage_units.units') ?? {}) };
|
|
101
|
+
const format = lookup(options, 'number.human.storage_units.format') ?? NUMBER_DEFAULTS.storage_units.format;
|
|
102
|
+
const n = Number(parseDecimal(number).int + '.' + (parseDecimal(number).frac || '0'));
|
|
103
|
+
let exponent = 0;
|
|
104
|
+
while (exponent < STORAGE.length - 1 && n >= 1024 ** (exponent + 1)) exponent += 1;
|
|
105
|
+
if (exponent === 0) {
|
|
106
|
+
const unit = units.byte;
|
|
107
|
+
const count = Math.trunc(n);
|
|
108
|
+
const unitText = typeof unit === 'object' ? (count === 1 ? unit.one : unit.other) : unit;
|
|
109
|
+
return format.replace('%n', String(count)).replace('%u', unitText);
|
|
110
|
+
}
|
|
111
|
+
const value = formatRounded(String(n / 1024 ** exponent), o);
|
|
112
|
+
return format.replace('%n', value).replace('%u', units[STORAGE[exponent]]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** number_to_phone(5551234) # => "555-1234" */
|
|
116
|
+
export function numberToPhone(number, options = {}) {
|
|
117
|
+
const o = snake(options);
|
|
118
|
+
const delimiter = o.delimiter ?? '-';
|
|
119
|
+
let s = String(number).trim();
|
|
120
|
+
if (o.area_code) {
|
|
121
|
+
s = s.replace(o.pattern ?? /(\d{1,3})(\d{3})(\d{4}$)/, `($1) $2${delimiter}$3`);
|
|
122
|
+
} else {
|
|
123
|
+
s = s.replace(o.pattern ?? /(\d{0,3})(\d{3})(\d{4})$/, `$1${delimiter}$2${delimiter}$3`);
|
|
124
|
+
if (delimiter && s.startsWith(delimiter)) s = s.slice(delimiter.length);
|
|
125
|
+
}
|
|
126
|
+
let out = '';
|
|
127
|
+
if (o.country_code !== undefined && o.country_code !== null && o.country_code !== '') out += `+${o.country_code}${delimiter}`;
|
|
128
|
+
out += s;
|
|
129
|
+
if (o.extension !== undefined && o.extension !== null && o.extension !== '') out += ` x ${o.extension}`;
|
|
130
|
+
return out;
|
|
131
|
+
}
|