@atscript/ui 0.1.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/index.cjs +1643 -0
- package/dist/index.d.cts +862 -0
- package/dist/index.d.mts +862 -0
- package/dist/index.mjs +1498 -0
- package/dist/plugin.cjs +515 -0
- package/dist/plugin.d.cts +18 -0
- package/dist/plugin.d.mts +19 -0
- package/dist/plugin.mjs +515 -0
- package/package.json +68 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
import { Validator, createDataFromAnnotatedType, deserializeAnnotatedType, detectDiscriminator, flattenAnnotatedType } from "@atscript/typescript/utils";
|
|
2
|
+
import { Client } from "@atscript/db-client";
|
|
3
|
+
//#region src/shared/annotation-keys.ts
|
|
4
|
+
const UI_TYPE = "ui.type";
|
|
5
|
+
const UI_FORM_PLACEHOLDER = "ui.form.placeholder";
|
|
6
|
+
const UI_FORM_HINT = "ui.form.hint";
|
|
7
|
+
const UI_FORM_CLASSES = "ui.form.classes";
|
|
8
|
+
const UI_FORM_STYLES = "ui.form.styles";
|
|
9
|
+
const UI_FORM_AUTOCOMPLETE = "ui.form.autocomplete";
|
|
10
|
+
const UI_FORM_DISABLED = "ui.form.disabled";
|
|
11
|
+
const UI_FORM_OPTIONS = "ui.form.options";
|
|
12
|
+
const UI_FORM_ORDER = "ui.form.order";
|
|
13
|
+
const UI_FORM_TYPE = "ui.form.type";
|
|
14
|
+
const UI_FORM_COMPONENT = "ui.form.component";
|
|
15
|
+
const UI_FORM_HIDDEN = "ui.form.hidden";
|
|
16
|
+
const UI_FORM_ATTR = "ui.form.attr";
|
|
17
|
+
const UI_FORM_GRID_COL_SPAN = "ui.form.grid.colSpan";
|
|
18
|
+
const UI_FORM_GRID_ROW_SPAN = "ui.form.grid.rowSpan";
|
|
19
|
+
const UI_FORM_SUBMIT_TEXT = "ui.form.submit.text";
|
|
20
|
+
const UI_FORM_LABEL_SINGULAR = "ui.form.label.singular";
|
|
21
|
+
const UI_FORM_ACTION = "ui.form.action";
|
|
22
|
+
const UI_FORM_PREFIX = "ui.form.prefix";
|
|
23
|
+
const UI_FORM_PREFIX_REF = "ui.form.prefix.ref";
|
|
24
|
+
const UI_FORM_PREFIX_ICON = "ui.form.prefix.icon";
|
|
25
|
+
const UI_FORM_SUFFIX = "ui.form.suffix";
|
|
26
|
+
const UI_FORM_SUFFIX_REF = "ui.form.suffix.ref";
|
|
27
|
+
const UI_FORM_SUFFIX_ICON = "ui.form.suffix.icon";
|
|
28
|
+
const UI_TABLE_WIDTH = "ui.table.width";
|
|
29
|
+
const UI_TABLE_COMPONENT = "ui.table.component";
|
|
30
|
+
const UI_TABLE_HIDDEN = "ui.table.hidden";
|
|
31
|
+
const UI_TABLE_ATTR = "ui.table.attr";
|
|
32
|
+
const UI_TABLE_CLASSES = "ui.table.classes";
|
|
33
|
+
const UI_TABLE_STYLES = "ui.table.styles";
|
|
34
|
+
const UI_TABLE_TYPE = "ui.table.type";
|
|
35
|
+
const UI_TABLE_ORDER = "ui.table.order";
|
|
36
|
+
const UI_DICT_LABEL = "ui.dict.label";
|
|
37
|
+
const UI_DICT_DESCR = "ui.dict.descr";
|
|
38
|
+
const UI_DICT_ATTR = "ui.dict.attr";
|
|
39
|
+
const UI_DICT_FILTERABLE = "ui.dict.filterable";
|
|
40
|
+
const UI_DICT_SORTABLE = "ui.dict.sortable";
|
|
41
|
+
const UI_DICT_SEARCHABLE = "ui.dict.searchable";
|
|
42
|
+
const DB_REL_FK = "db.rel.FK";
|
|
43
|
+
const DB_HTTP_PATH = "db.http.path";
|
|
44
|
+
const DB_AMOUNT_CURRENCY = "db.amount.currency";
|
|
45
|
+
const DB_AMOUNT_CURRENCY_REF = "db.amount.currency.ref";
|
|
46
|
+
const DB_UNIT = "db.unit";
|
|
47
|
+
const DB_UNIT_REF = "db.unit.ref";
|
|
48
|
+
const DB_COLUMN_PRECISION = "db.column.precision";
|
|
49
|
+
const WF_ACTION_WITH_DATA = "wf.action.withData";
|
|
50
|
+
const META_LABEL = "meta.label";
|
|
51
|
+
const META_ID = "meta.id";
|
|
52
|
+
const META_DESCRIPTION = "meta.description";
|
|
53
|
+
const META_READONLY = "meta.readonly";
|
|
54
|
+
const META_REQUIRED = "meta.required";
|
|
55
|
+
const META_DEFAULT = "meta.default";
|
|
56
|
+
const META_SENSITIVE = "meta.sensitive";
|
|
57
|
+
const EXPECT_MAX_LENGTH = "expect.maxLength";
|
|
58
|
+
const UI_FORM_FN_PREFIX = "ui.form.fn.";
|
|
59
|
+
const UI_FORM_FN_LABEL = "ui.form.fn.label";
|
|
60
|
+
const UI_FORM_FN_PLACEHOLDER = "ui.form.fn.placeholder";
|
|
61
|
+
const UI_FORM_FN_DESCRIPTION = "ui.form.fn.description";
|
|
62
|
+
const UI_FORM_FN_HINT = "ui.form.fn.hint";
|
|
63
|
+
const UI_FORM_FN_HIDDEN = "ui.form.fn.hidden";
|
|
64
|
+
const UI_FORM_FN_DISABLED = "ui.form.fn.disabled";
|
|
65
|
+
const UI_FORM_FN_READONLY = "ui.form.fn.readonly";
|
|
66
|
+
const UI_FORM_FN_OPTIONS = "ui.form.fn.options";
|
|
67
|
+
const UI_FORM_FN_ATTR = "ui.form.fn.attr";
|
|
68
|
+
const UI_FORM_FN_VALUE = "ui.form.fn.value";
|
|
69
|
+
const UI_FORM_FN_CLASSES = "ui.form.fn.classes";
|
|
70
|
+
const UI_FORM_FN_STYLES = "ui.form.fn.styles";
|
|
71
|
+
const UI_FORM_FN_TITLE = "ui.form.fn.title";
|
|
72
|
+
const UI_FORM_FN_SUBMIT_TEXT = "ui.form.fn.submit.text";
|
|
73
|
+
const UI_FORM_FN_SUBMIT_DISABLED = "ui.form.fn.submit.disabled";
|
|
74
|
+
const UI_TABLE_FN_PREFIX = "ui.table.fn.";
|
|
75
|
+
const UI_TABLE_FN_ATTR = "ui.table.fn.attr";
|
|
76
|
+
const UI_TABLE_FN_CLASSES = "ui.table.fn.classes";
|
|
77
|
+
const UI_TABLE_FN_STYLES = "ui.table.fn.styles";
|
|
78
|
+
const UI_FORM_VALIDATE = "ui.form.validate";
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/form/types.ts
|
|
81
|
+
/** Type guard: checks if a field def is an array field. */
|
|
82
|
+
function isArrayField(field) {
|
|
83
|
+
return field.type === "array";
|
|
84
|
+
}
|
|
85
|
+
/** Type guard: checks if a field def is an object field. */
|
|
86
|
+
function isObjectField(field) {
|
|
87
|
+
return field.type === "object";
|
|
88
|
+
}
|
|
89
|
+
/** Type guard: checks if a field def is a union field. */
|
|
90
|
+
function isUnionField(field) {
|
|
91
|
+
return field.type === "union";
|
|
92
|
+
}
|
|
93
|
+
/** Type guard: checks if a field def is a tuple field. */
|
|
94
|
+
function isTupleField(field) {
|
|
95
|
+
return field.type === "tuple";
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/shared/field-resolver.ts
|
|
99
|
+
/** Static resolver — ignores fn keys, reads only static metadata. */
|
|
100
|
+
var StaticFieldResolver = class {
|
|
101
|
+
resolveFieldProp(prop, _fnKey, staticKey, _scope, opts) {
|
|
102
|
+
return resolveStatic(prop.metadata, staticKey, opts);
|
|
103
|
+
}
|
|
104
|
+
resolveFormProp(type, _fnKey, staticKey, _scope, opts) {
|
|
105
|
+
return resolveStatic(type.metadata, staticKey, opts);
|
|
106
|
+
}
|
|
107
|
+
hasComputedAnnotations(_prop) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** Resolves a static metadata value. Exported for reuse by dynamic resolvers. */
|
|
112
|
+
function resolveStatic(metadata, staticKey, opts) {
|
|
113
|
+
if (staticKey === void 0) return void 0;
|
|
114
|
+
const staticVal = metadata.get(staticKey);
|
|
115
|
+
if (staticVal !== void 0) {
|
|
116
|
+
if (opts?.staticAsBoolean) return true;
|
|
117
|
+
if (opts?.transform) return opts.transform(staticVal);
|
|
118
|
+
return staticVal;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Default static resolver instance. */
|
|
122
|
+
const defaultResolver = new StaticFieldResolver();
|
|
123
|
+
let activeResolver = defaultResolver;
|
|
124
|
+
/** Replace the active resolver (called by ui-fns to install dynamic resolution). */
|
|
125
|
+
function setResolver(resolver) {
|
|
126
|
+
activeResolver = resolver;
|
|
127
|
+
}
|
|
128
|
+
/** Get the current active resolver. */
|
|
129
|
+
function getResolver() {
|
|
130
|
+
return activeResolver;
|
|
131
|
+
}
|
|
132
|
+
/** Resolve a field-level metadata property via the active resolver. */
|
|
133
|
+
function resolveFieldProp(prop, fnKey, staticKey, scope, opts) {
|
|
134
|
+
return activeResolver.resolveFieldProp(prop, fnKey, staticKey, scope, opts);
|
|
135
|
+
}
|
|
136
|
+
/** Resolve a form-level metadata property via the active resolver. */
|
|
137
|
+
function resolveFormProp(type, fnKey, staticKey, scope, opts) {
|
|
138
|
+
return activeResolver.resolveFormProp(type, fnKey, staticKey, scope, opts);
|
|
139
|
+
}
|
|
140
|
+
/** Check if a prop has dynamic annotations via the active resolver. */
|
|
141
|
+
function hasComputedAnnotations(prop) {
|
|
142
|
+
return activeResolver.hasComputedAnnotations(prop);
|
|
143
|
+
}
|
|
144
|
+
function getFieldMeta(prop, key) {
|
|
145
|
+
return prop.metadata.get(key);
|
|
146
|
+
}
|
|
147
|
+
/** Ensures a value is an array — returns as-is if already one, wraps in `[x]` otherwise. */
|
|
148
|
+
function asArray(x) {
|
|
149
|
+
return Array.isArray(x) ? x : [x];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parses static `ui.attr` metadata into a key-value record.
|
|
153
|
+
* Exported so ui-fns can reuse this without duplicating the parsing logic.
|
|
154
|
+
*/
|
|
155
|
+
function parseStaticAttrs(staticAttrs) {
|
|
156
|
+
if (!staticAttrs) return void 0;
|
|
157
|
+
const result = {};
|
|
158
|
+
let hasAttrs = false;
|
|
159
|
+
for (const item of asArray(staticAttrs)) if (typeof item === "object" && item !== null && "name" in item && "value" in item) {
|
|
160
|
+
const { name, value } = item;
|
|
161
|
+
result[name] = value;
|
|
162
|
+
hasAttrs = true;
|
|
163
|
+
}
|
|
164
|
+
return hasAttrs ? result : void 0;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Resolves `<staticKey>` + `<fnKey>` attr metadata on demand.
|
|
168
|
+
* Defaults read `ui.form.attr` + `ui.form.fn.attr`; pass `{ staticKey, fnKey }` to read
|
|
169
|
+
* the table-side pair (`ui.table.attr` + `ui.table.fn.attr`) or any other surface.
|
|
170
|
+
*/
|
|
171
|
+
function resolveAttrs(prop, scope, keys = {}) {
|
|
172
|
+
const staticKey = keys.staticKey ?? "ui.form.attr";
|
|
173
|
+
const fnKey = keys.fnKey ?? "ui.form.fn.attr";
|
|
174
|
+
const staticAttrs = getFieldMeta(prop, staticKey);
|
|
175
|
+
const fnAttrs = getFieldMeta(prop, fnKey);
|
|
176
|
+
if (!staticAttrs && !fnAttrs) return void 0;
|
|
177
|
+
const result = {};
|
|
178
|
+
let hasAttrs = false;
|
|
179
|
+
const parsed = parseStaticAttrs(staticAttrs);
|
|
180
|
+
if (parsed) {
|
|
181
|
+
Object.assign(result, parsed);
|
|
182
|
+
hasAttrs = true;
|
|
183
|
+
}
|
|
184
|
+
if (fnAttrs) {
|
|
185
|
+
const resolved = resolveFieldProp(prop, fnKey, void 0, scope);
|
|
186
|
+
if (resolved) {
|
|
187
|
+
Object.assign(result, resolved);
|
|
188
|
+
hasAttrs = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return hasAttrs ? result : void 0;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/value-help/extract-literals.ts
|
|
195
|
+
/**
|
|
196
|
+
* Extracts options from a union of literal types (e.g. 'a' | 'b' | 'c').
|
|
197
|
+
* Returns undefined if the type is not a pure union of literals.
|
|
198
|
+
*
|
|
199
|
+
* Handles nested unions created by flattenAnnotatedType, which recurses
|
|
200
|
+
* into union items and produces synthetic unions containing both individual
|
|
201
|
+
* literals and the original union type as nested items.
|
|
202
|
+
*/
|
|
203
|
+
function extractLiteralOptions(prop) {
|
|
204
|
+
if (prop.type.kind !== "union") return void 0;
|
|
205
|
+
const result = collectLiterals(prop.type.items, /* @__PURE__ */ new Set());
|
|
206
|
+
return result && result.length > 0 ? result : void 0;
|
|
207
|
+
}
|
|
208
|
+
/** Returns true when the annotated type is a union composed entirely of literal values. */
|
|
209
|
+
function isPureLiteralUnion(prop) {
|
|
210
|
+
if (prop.type.kind !== "union") return false;
|
|
211
|
+
return checkAllLiterals(prop.type.items);
|
|
212
|
+
}
|
|
213
|
+
/** Walks union items returning true only if every leaf is a literal value. */
|
|
214
|
+
function checkAllLiterals(items) {
|
|
215
|
+
for (const item of items) {
|
|
216
|
+
if (item.type.kind === "" && item.type.value !== void 0) continue;
|
|
217
|
+
if (item.type.kind === "union") {
|
|
218
|
+
if (!checkAllLiterals(item.type.items)) return false;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return items.length > 0;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Recursively collects literal values from union items.
|
|
227
|
+
* Returns null if any non-literal, non-union item is found (invalid union).
|
|
228
|
+
* Returns empty array when all items are valid but already seen (deduped).
|
|
229
|
+
*/
|
|
230
|
+
function collectLiterals(items, seen) {
|
|
231
|
+
const result = [];
|
|
232
|
+
for (const item of items) if (item.type.kind === "" && item.type.value !== void 0) {
|
|
233
|
+
const val = String(item.type.value);
|
|
234
|
+
if (!seen.has(val)) {
|
|
235
|
+
seen.add(val);
|
|
236
|
+
result.push({
|
|
237
|
+
key: val,
|
|
238
|
+
label: val
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
} else if (item.type.kind === "union") {
|
|
242
|
+
const nested = collectLiterals(item.type.items, seen);
|
|
243
|
+
if (nested === null) return null;
|
|
244
|
+
result.push(...nested);
|
|
245
|
+
} else return null;
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/value-help/extract-ref.ts
|
|
250
|
+
/**
|
|
251
|
+
* Synchronous probe. Returns `{ url, targetField }` iff:
|
|
252
|
+
* 1. the prop carries `@db.rel.FK`,
|
|
253
|
+
* 2. the prop has a `.ref`,
|
|
254
|
+
* 3. the ref's target metadata carries `@db.http.path`.
|
|
255
|
+
*/
|
|
256
|
+
function extractValueHelp(prop) {
|
|
257
|
+
if (!prop.metadata.has("db.rel.FK")) return void 0;
|
|
258
|
+
if (!prop.ref) return void 0;
|
|
259
|
+
const target = prop.ref.type();
|
|
260
|
+
if (!target) return void 0;
|
|
261
|
+
const url = target.metadata.get(DB_HTTP_PATH);
|
|
262
|
+
if (!url) return void 0;
|
|
263
|
+
return {
|
|
264
|
+
url,
|
|
265
|
+
targetField: prop.ref.field
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/form/create-form-def.ts
|
|
270
|
+
/** Known atscript primitive extension tags that map directly to field types. */
|
|
271
|
+
const UI_TAGS = new Set([
|
|
272
|
+
"action",
|
|
273
|
+
"paragraph",
|
|
274
|
+
"select",
|
|
275
|
+
"radio",
|
|
276
|
+
"checkbox"
|
|
277
|
+
]);
|
|
278
|
+
/**
|
|
279
|
+
* Converts an ATScript annotated type into a FormDef.
|
|
280
|
+
*
|
|
281
|
+
* - **Object types** (`kind === 'object'`): produces an object root with nested fields.
|
|
282
|
+
* - **Non-object types** (primitive, array, union, etc.): produces a single leaf root field
|
|
283
|
+
* with `path: ''`.
|
|
284
|
+
*/
|
|
285
|
+
function createFormDef(type) {
|
|
286
|
+
if (type.type.kind !== "object") {
|
|
287
|
+
const rootField = createFieldDef("", type);
|
|
288
|
+
return {
|
|
289
|
+
type,
|
|
290
|
+
rootField,
|
|
291
|
+
fields: [rootField],
|
|
292
|
+
flatMap: /* @__PURE__ */ new Map()
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const objectType = type;
|
|
296
|
+
const flatMap = flattenAnnotatedType(objectType, { excludePhantomTypes: false });
|
|
297
|
+
const fields = [];
|
|
298
|
+
const structuredPrefixes = /* @__PURE__ */ new Set();
|
|
299
|
+
for (const [path, prop] of flatMap.entries()) {
|
|
300
|
+
if (path === "") continue;
|
|
301
|
+
if (isChildOfStructured(path, structuredPrefixes)) continue;
|
|
302
|
+
const originalProp = resolveOriginalProp(objectType, path) ?? prop;
|
|
303
|
+
const kind = originalProp.type.kind;
|
|
304
|
+
if (kind === "object") {
|
|
305
|
+
const hasLabel = getFieldMeta(originalProp, META_LABEL) !== void 0;
|
|
306
|
+
const hasComponent = getFieldMeta(originalProp, UI_FORM_COMPONENT) !== void 0;
|
|
307
|
+
if (!hasLabel && !hasComponent) continue;
|
|
308
|
+
}
|
|
309
|
+
if (kind === "array") {
|
|
310
|
+
if (originalProp.type.of.type.kind === "array" && !getFieldMeta(originalProp, "ui.form.component")) continue;
|
|
311
|
+
}
|
|
312
|
+
if (kind === "array" || kind === "object" || kind === "union" || kind === "tuple") structuredPrefixes.add(path + ".");
|
|
313
|
+
fields.push(createFieldDef(path, originalProp));
|
|
314
|
+
}
|
|
315
|
+
const orderMap = new Map(fields.map((f) => [f, getFieldMeta(f.prop, "ui.form.order") ?? Infinity]));
|
|
316
|
+
fields.sort((a, b) => orderMap.get(a) - orderMap.get(b));
|
|
317
|
+
const rootField = {
|
|
318
|
+
path: "",
|
|
319
|
+
prop: type,
|
|
320
|
+
type: "object",
|
|
321
|
+
phantom: false,
|
|
322
|
+
name: "",
|
|
323
|
+
allStatic: false
|
|
324
|
+
};
|
|
325
|
+
const def = {
|
|
326
|
+
type,
|
|
327
|
+
rootField,
|
|
328
|
+
fields,
|
|
329
|
+
flatMap
|
|
330
|
+
};
|
|
331
|
+
rootField.objectDef = def;
|
|
332
|
+
return def;
|
|
333
|
+
}
|
|
334
|
+
/** Creates a FormFieldDef from any ATScript annotated type. */
|
|
335
|
+
function createFieldDef(path, prop) {
|
|
336
|
+
const kind = prop.type.kind;
|
|
337
|
+
const name = path.slice(path.lastIndexOf(".") + 1);
|
|
338
|
+
const allStatic = !hasComputedAnnotations(prop);
|
|
339
|
+
const uiType = getFieldMeta(prop, "ui.form.type") ?? getFieldMeta(prop, "ui.type");
|
|
340
|
+
const base = {
|
|
341
|
+
path,
|
|
342
|
+
prop,
|
|
343
|
+
phantom: false,
|
|
344
|
+
name,
|
|
345
|
+
allStatic
|
|
346
|
+
};
|
|
347
|
+
const customType = uiType;
|
|
348
|
+
if (kind === "array") {
|
|
349
|
+
const arrayType = prop.type;
|
|
350
|
+
return {
|
|
351
|
+
...base,
|
|
352
|
+
type: "array",
|
|
353
|
+
customType,
|
|
354
|
+
itemType: arrayType.of,
|
|
355
|
+
itemField: createFieldDef("", arrayType.of)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (kind === "object") return {
|
|
359
|
+
...base,
|
|
360
|
+
type: "object",
|
|
361
|
+
customType,
|
|
362
|
+
objectDef: createFormDef(prop)
|
|
363
|
+
};
|
|
364
|
+
if (kind === "union") {
|
|
365
|
+
if (isPureLiteralUnion(prop)) return {
|
|
366
|
+
...base,
|
|
367
|
+
type: uiType ?? "select"
|
|
368
|
+
};
|
|
369
|
+
const unionVariants = buildUnionVariants(prop);
|
|
370
|
+
if (unionVariants.length > 1) return {
|
|
371
|
+
...base,
|
|
372
|
+
type: "union",
|
|
373
|
+
customType,
|
|
374
|
+
unionVariants
|
|
375
|
+
};
|
|
376
|
+
const v = unionVariants[0];
|
|
377
|
+
if (v?.itemField) return {
|
|
378
|
+
...v.itemField,
|
|
379
|
+
path,
|
|
380
|
+
name,
|
|
381
|
+
allStatic
|
|
382
|
+
};
|
|
383
|
+
if (v?.def) return {
|
|
384
|
+
...base,
|
|
385
|
+
type: "object",
|
|
386
|
+
customType,
|
|
387
|
+
objectDef: v.def
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (kind === "tuple") {
|
|
391
|
+
const tupleType = prop.type;
|
|
392
|
+
return {
|
|
393
|
+
...base,
|
|
394
|
+
type: "tuple",
|
|
395
|
+
customType,
|
|
396
|
+
itemFields: tupleType.items.map((item, i) => {
|
|
397
|
+
const field = createFieldDef(String(i), item);
|
|
398
|
+
field.name = "";
|
|
399
|
+
return field;
|
|
400
|
+
})
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (extractValueHelp(prop)) return {
|
|
404
|
+
...base,
|
|
405
|
+
type: uiType ?? "ref"
|
|
406
|
+
};
|
|
407
|
+
const tags = kind === "" ? prop.type.tags : void 0;
|
|
408
|
+
let uiTag;
|
|
409
|
+
let isTimestamp = false;
|
|
410
|
+
if (tags) for (const t of tags) {
|
|
411
|
+
if (uiTag === void 0 && UI_TAGS.has(t)) uiTag = t;
|
|
412
|
+
if (t === "timestamp") isTimestamp = true;
|
|
413
|
+
}
|
|
414
|
+
const dt = kind === "" ? prop.type.designType : void 0;
|
|
415
|
+
let numericType;
|
|
416
|
+
if (kind === "") {
|
|
417
|
+
const hasCurrency = getFieldMeta(prop, "db.amount.currency") !== void 0 || getFieldMeta(prop, "db.amount.currency.ref") !== void 0;
|
|
418
|
+
const isDecimalDesign = dt === "decimal";
|
|
419
|
+
if (hasCurrency) numericType = "decimal";
|
|
420
|
+
else if (isDecimalDesign) numericType = "decimal";
|
|
421
|
+
else if (dt === "number") {
|
|
422
|
+
const hasUnit = getFieldMeta(prop, "db.unit") !== void 0 || getFieldMeta(prop, "db.unit.ref") !== void 0;
|
|
423
|
+
const hasPrefix = getFieldMeta(prop, "ui.form.prefix") !== void 0 || getFieldMeta(prop, "ui.form.prefix.ref") !== void 0 || getFieldMeta(prop, "ui.form.prefix.icon") !== void 0;
|
|
424
|
+
const hasSuffix = getFieldMeta(prop, "ui.form.suffix") !== void 0 || getFieldMeta(prop, "ui.form.suffix.ref") !== void 0 || getFieldMeta(prop, "ui.form.suffix.icon") !== void 0;
|
|
425
|
+
if (hasUnit || hasPrefix || hasSuffix) numericType = "number";
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const tagType = isTimestamp ? "datetime" : void 0;
|
|
429
|
+
return {
|
|
430
|
+
...base,
|
|
431
|
+
type: uiType ?? uiTag ?? numericType ?? tagType ?? (dt === "number" ? "number" : dt === "boolean" ? "checkbox" : "text"),
|
|
432
|
+
phantom: kind === "" && dt === "phantom"
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** Resolves the original annotated type from the type hierarchy by path. */
|
|
436
|
+
function resolveOriginalProp(type, path) {
|
|
437
|
+
const parts = path.split(".");
|
|
438
|
+
let current = type.type;
|
|
439
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
440
|
+
const prop = current.props.get(parts[i]);
|
|
441
|
+
if (!prop || prop.type.kind !== "object") return void 0;
|
|
442
|
+
current = prop.type;
|
|
443
|
+
}
|
|
444
|
+
return current.props.get(parts[parts.length - 1]);
|
|
445
|
+
}
|
|
446
|
+
/** Check if a path is a child of any structured prefix (prefixes are pre-suffixed with "."). */
|
|
447
|
+
function isChildOfStructured(path, prefixes) {
|
|
448
|
+
for (const prefix of prefixes) if (path.startsWith(prefix)) return true;
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Builds union variant definitions from a union annotated type.
|
|
453
|
+
* Iterates top-level items directly — one variant per item.
|
|
454
|
+
*/
|
|
455
|
+
function buildUnionVariants(typeDef) {
|
|
456
|
+
const items = typeDef.type.items ?? [typeDef];
|
|
457
|
+
const variants = [];
|
|
458
|
+
for (const item of items) {
|
|
459
|
+
const v = createVariant(item);
|
|
460
|
+
if (items.length > 1) v.label = `${String(variants.length + 1)}. ${v.label}`;
|
|
461
|
+
variants.push(v);
|
|
462
|
+
}
|
|
463
|
+
return variants;
|
|
464
|
+
}
|
|
465
|
+
/** Creates a single union variant from an annotated type item. */
|
|
466
|
+
function createVariant(def) {
|
|
467
|
+
const kind = def.type.kind;
|
|
468
|
+
if (kind === "object") {
|
|
469
|
+
const label = getFieldMeta(def, "meta.label") ?? "Object";
|
|
470
|
+
const hasComponent = getFieldMeta(def, UI_FORM_COMPONENT) !== void 0;
|
|
471
|
+
return {
|
|
472
|
+
label,
|
|
473
|
+
type: def,
|
|
474
|
+
def: createFormDef(def),
|
|
475
|
+
itemField: hasComponent ? createFieldDef("", def) : void 0
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (kind === "") {
|
|
479
|
+
const dt = def.type.designType;
|
|
480
|
+
return {
|
|
481
|
+
label: capitalize(dt === "phantom" ? "item" : dt),
|
|
482
|
+
type: def,
|
|
483
|
+
itemField: createFieldDef("", def),
|
|
484
|
+
designType: dt
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
label: capitalize(kind),
|
|
489
|
+
type: def,
|
|
490
|
+
itemField: createFieldDef("", def)
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function capitalize(s) {
|
|
494
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/value-help/dict-paths.ts
|
|
498
|
+
/**
|
|
499
|
+
* Paths that make up the "dict view" of a value-help target:
|
|
500
|
+
* PKs + label + descr + attr fields. Used by filter dialogs to clamp
|
|
501
|
+
* visible columns to the dictionary subset.
|
|
502
|
+
*/
|
|
503
|
+
function valueHelpDictPaths(resolved) {
|
|
504
|
+
const paths = [
|
|
505
|
+
...resolved.primaryKeys,
|
|
506
|
+
resolved.labelField,
|
|
507
|
+
...resolved.attrFields
|
|
508
|
+
];
|
|
509
|
+
if (resolved.descrField) paths.push(resolved.descrField);
|
|
510
|
+
return new Set(paths);
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region src/value-help/resolve-options.ts
|
|
514
|
+
/** Extracts the key from an option entry. */
|
|
515
|
+
function optKey(opt) {
|
|
516
|
+
return typeof opt === "string" ? opt : opt.key;
|
|
517
|
+
}
|
|
518
|
+
/** Extracts the display label from an option entry. */
|
|
519
|
+
function optLabel(opt) {
|
|
520
|
+
return typeof opt === "string" ? opt : opt.label;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Converts raw option annotation value to a normalized array.
|
|
524
|
+
*/
|
|
525
|
+
function parseStaticOptions(raw) {
|
|
526
|
+
return asArray(raw).map((item) => {
|
|
527
|
+
if (typeof item === "object" && item !== null && "label" in item) {
|
|
528
|
+
const { label, value } = item;
|
|
529
|
+
return value !== void 0 ? {
|
|
530
|
+
key: value,
|
|
531
|
+
label
|
|
532
|
+
} : label;
|
|
533
|
+
}
|
|
534
|
+
return String(item);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Resolves options from metadata with a fallback chain:
|
|
539
|
+
* 1. `@ui.form.fn.options` (dynamic, compiled by ui-fns)
|
|
540
|
+
* 2. `@ui.form.options` (static annotation)
|
|
541
|
+
* 3. Literal union type extraction (auto-derived from type)
|
|
542
|
+
* 4. Future: dictionary / value-help lookup
|
|
543
|
+
*/
|
|
544
|
+
function resolveOptions(prop, scope) {
|
|
545
|
+
const resolved = resolveFieldProp(prop, UI_FORM_FN_OPTIONS, UI_FORM_OPTIONS, scope, { transform: parseStaticOptions });
|
|
546
|
+
if (resolved !== void 0) return resolved;
|
|
547
|
+
return extractLiteralOptions(prop);
|
|
548
|
+
}
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/value-help/value-help-client.ts
|
|
551
|
+
/**
|
|
552
|
+
* Value-help query client. Wraps a `Client` from `@atscript/db-client`
|
|
553
|
+
* with FK-specific search logic (regex fallback for non-searchable tables,
|
|
554
|
+
* $select scoping).
|
|
555
|
+
*
|
|
556
|
+
* Consumers resolve the target's metadata once via `resolveValueHelp(url)`
|
|
557
|
+
* and pass the resulting `ResolvedValueHelp` to `search()`. Label resolution
|
|
558
|
+
* for cells is deliberately unsupported — cells always display raw ids.
|
|
559
|
+
*/
|
|
560
|
+
var ValueHelpClient = class {
|
|
561
|
+
_client;
|
|
562
|
+
constructor(client) {
|
|
563
|
+
this._client = client;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Search the target with value-help semantics.
|
|
567
|
+
*
|
|
568
|
+
* - If target is searchable → sends `$search` (server full-text)
|
|
569
|
+
* - If not searchable → sends `$or` regex across select fields + exact PK match
|
|
570
|
+
*/
|
|
571
|
+
async search(resolved, opts) {
|
|
572
|
+
const mode = opts?.mode ?? "form";
|
|
573
|
+
const limit = opts?.limit ?? 20;
|
|
574
|
+
const selectFields = opts?.select ?? computeSelectFields(resolved, mode);
|
|
575
|
+
const text = opts?.text;
|
|
576
|
+
if (!text) return { items: await this._client.query({ controls: {
|
|
577
|
+
$select: selectFields,
|
|
578
|
+
$limit: limit
|
|
579
|
+
} }) };
|
|
580
|
+
if (resolved.searchable) return { items: await this._client.query({ controls: {
|
|
581
|
+
$select: selectFields,
|
|
582
|
+
$limit: limit,
|
|
583
|
+
$search: text
|
|
584
|
+
} }) };
|
|
585
|
+
const filter = buildOrFilter(text, selectFields, resolved.primaryKeys[0] ?? resolved.labelField);
|
|
586
|
+
return { items: await this._client.query({
|
|
587
|
+
filter,
|
|
588
|
+
controls: {
|
|
589
|
+
$select: selectFields,
|
|
590
|
+
$limit: limit
|
|
591
|
+
}
|
|
592
|
+
}) };
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/**
|
|
596
|
+
* Compute the $select fields for a value-help query.
|
|
597
|
+
*/
|
|
598
|
+
function computeSelectFields(resolved, mode) {
|
|
599
|
+
const fields = [...resolved.primaryKeys, resolved.labelField];
|
|
600
|
+
if (resolved.descrField) fields.push(resolved.descrField);
|
|
601
|
+
if (mode === "filter") fields.push(...resolved.attrFields);
|
|
602
|
+
return [...new Set(fields)];
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Build a Uniquery `$or` filter for value-help search across multiple fields.
|
|
606
|
+
* Uses regex startsWith (case-insensitive) on text fields + exact match on PK.
|
|
607
|
+
*/
|
|
608
|
+
function buildOrFilter(text, fields, pkField) {
|
|
609
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
610
|
+
const conditions = [];
|
|
611
|
+
for (const field of fields) if (field !== pkField) conditions.push({ [field]: { $regex: `/^${escaped}/i` } });
|
|
612
|
+
const asNum = Number(text);
|
|
613
|
+
if (!Number.isNaN(asNum)) conditions.push({ [pkField]: asNum });
|
|
614
|
+
else conditions.push({ [pkField]: text });
|
|
615
|
+
return { $or: conditions };
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/client-factory.ts
|
|
619
|
+
const builtin = (url) => new Client(url);
|
|
620
|
+
let _default = builtin;
|
|
621
|
+
/**
|
|
622
|
+
* Override the app-wide default factory. Call once at startup (e.g. in
|
|
623
|
+
* `entry-client.ts`) to wire shared fetch, credentials, error handling, etc.
|
|
624
|
+
* Every table, value-help picker, and other client consumer will pick it up.
|
|
625
|
+
*/
|
|
626
|
+
function setDefaultClientFactory(factory) {
|
|
627
|
+
_default = factory;
|
|
628
|
+
}
|
|
629
|
+
/** Current app-wide default factory. Falls back to `new Client(url)`. */
|
|
630
|
+
function getDefaultClientFactory() {
|
|
631
|
+
return _default;
|
|
632
|
+
}
|
|
633
|
+
/** Reset the default factory to the built-in one (primarily for tests). */
|
|
634
|
+
function resetDefaultClientFactory() {
|
|
635
|
+
_default = builtin;
|
|
636
|
+
}
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/shared/meta-cache.ts
|
|
639
|
+
const cache = /* @__PURE__ */ new Map();
|
|
640
|
+
/**
|
|
641
|
+
* Get or create the cache entry for `url`. First caller's `factory` seeds the
|
|
642
|
+
* `Client`; subsequent callers reuse it. On `meta` rejection, the entry is
|
|
643
|
+
* evicted so the next call retries.
|
|
644
|
+
*/
|
|
645
|
+
function getMetaEntry(url, factory) {
|
|
646
|
+
const existing = cache.get(url);
|
|
647
|
+
if (existing) return existing;
|
|
648
|
+
const client = (factory ?? getDefaultClientFactory())(url);
|
|
649
|
+
const meta = client.meta().catch((err) => {
|
|
650
|
+
cache.delete(url);
|
|
651
|
+
throw err;
|
|
652
|
+
});
|
|
653
|
+
const entry = {
|
|
654
|
+
client,
|
|
655
|
+
meta,
|
|
656
|
+
type: meta.then((m) => deserializeAnnotatedType(m.type))
|
|
657
|
+
};
|
|
658
|
+
cache.set(url, entry);
|
|
659
|
+
return entry;
|
|
660
|
+
}
|
|
661
|
+
function resetMetaCache() {
|
|
662
|
+
cache.clear();
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/value-help/resolve.ts
|
|
666
|
+
/**
|
|
667
|
+
* Lazily fetch and extract value-help metadata for the given URL.
|
|
668
|
+
*
|
|
669
|
+
* - Issues exactly one `GET {url}/meta` per URL across the session (shared via meta-cache).
|
|
670
|
+
* - Concurrent callers with the same URL share the in-flight promise.
|
|
671
|
+
* - If the underlying fetch rejects, the cache entry is evicted so a later
|
|
672
|
+
* retry performs a fresh fetch.
|
|
673
|
+
*/
|
|
674
|
+
function resolveValueHelp(url) {
|
|
675
|
+
const entry = getMetaEntry(url);
|
|
676
|
+
if (entry.resolved) return entry.resolved;
|
|
677
|
+
entry.resolved = Promise.all([entry.meta, entry.type]).then(([meta, type]) => buildResolved(url, meta, type));
|
|
678
|
+
return entry.resolved;
|
|
679
|
+
}
|
|
680
|
+
/** Thin alias over `resetMetaCache` — retained so existing test code keeps working. */
|
|
681
|
+
function resetValueHelpCache() {
|
|
682
|
+
resetMetaCache();
|
|
683
|
+
}
|
|
684
|
+
function buildResolved(url, meta, targetType) {
|
|
685
|
+
const objectType = targetType.type.kind === "object" ? targetType.type : void 0;
|
|
686
|
+
const primaryKeys = [];
|
|
687
|
+
let labelField;
|
|
688
|
+
let descrField;
|
|
689
|
+
const attrFields = [];
|
|
690
|
+
let firstStringField;
|
|
691
|
+
if (objectType) for (const [name, fieldProp] of objectType.props) {
|
|
692
|
+
const isPK = fieldProp.metadata.has(META_ID);
|
|
693
|
+
if (isPK) primaryKeys.push(name);
|
|
694
|
+
if (fieldProp.metadata.has("ui.dict.label")) labelField = name;
|
|
695
|
+
if (fieldProp.metadata.has("ui.dict.descr")) descrField = name;
|
|
696
|
+
if (fieldProp.metadata.has("ui.dict.attr")) attrFields.push(name);
|
|
697
|
+
if (!firstStringField && !isPK && fieldProp.type.kind === "" && fieldProp.type.designType === "string") firstStringField = name;
|
|
698
|
+
}
|
|
699
|
+
if (!labelField) labelField = firstStringField;
|
|
700
|
+
if (!labelField) labelField = primaryKeys[0] ?? "id";
|
|
701
|
+
const filterableFields = [];
|
|
702
|
+
const sortableFields = [];
|
|
703
|
+
if (meta.fields) for (const [name, fm] of Object.entries(meta.fields)) {
|
|
704
|
+
if (fm?.filterable) filterableFields.push(name);
|
|
705
|
+
if (fm?.sortable) sortableFields.push(name);
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
url,
|
|
709
|
+
primaryKeys,
|
|
710
|
+
labelField,
|
|
711
|
+
descrField,
|
|
712
|
+
attrFields,
|
|
713
|
+
filterableFields,
|
|
714
|
+
sortableFields,
|
|
715
|
+
searchable: !!meta.searchable,
|
|
716
|
+
targetType
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/form/path-utils.ts
|
|
721
|
+
/**
|
|
722
|
+
* Gets a nested value by dot-separated path.
|
|
723
|
+
* Always dereferences `obj.value` first (form data is wrapped in `{ value: domainData }`).
|
|
724
|
+
* When `path` is empty, returns the root domain data (`obj.value`).
|
|
725
|
+
*/
|
|
726
|
+
function getByPath(obj, path) {
|
|
727
|
+
const root = obj.value;
|
|
728
|
+
if (!path) return root;
|
|
729
|
+
const keys = path.split(".");
|
|
730
|
+
let current = root;
|
|
731
|
+
for (const key of keys) {
|
|
732
|
+
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
733
|
+
current = current[key];
|
|
734
|
+
}
|
|
735
|
+
return current;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Sets a nested value by dot-separated path.
|
|
739
|
+
* Always dereferences `obj.value` first (form data is wrapped in `{ value: domainData }`).
|
|
740
|
+
* When `path` is empty, sets the root domain data (`obj.value = value`).
|
|
741
|
+
* Creates intermediate objects if they do not exist.
|
|
742
|
+
*/
|
|
743
|
+
function setByPath(obj, path, value) {
|
|
744
|
+
if (!path) {
|
|
745
|
+
obj.value = value;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const keys = path.split(".");
|
|
749
|
+
const last = keys.pop();
|
|
750
|
+
if (last === void 0) return;
|
|
751
|
+
let current = obj.value;
|
|
752
|
+
for (const key of keys) {
|
|
753
|
+
if (current[key] === null || current[key] === void 0 || typeof current[key] !== "object") current[key] = {};
|
|
754
|
+
current = current[key];
|
|
755
|
+
}
|
|
756
|
+
current[last] = value;
|
|
757
|
+
}
|
|
758
|
+
function parseStaticDefault(raw, prop) {
|
|
759
|
+
if (typeof raw !== "string") return raw;
|
|
760
|
+
if (prop.type.kind === "" && prop.type.designType === "string") return raw;
|
|
761
|
+
try {
|
|
762
|
+
return JSON.parse(raw);
|
|
763
|
+
} catch {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Cached default resolver — reused when no resolver is provided. */
|
|
768
|
+
const defaultValueResolver = createFormValueResolver();
|
|
769
|
+
function createFormValueResolver(data = {}, context = {}) {
|
|
770
|
+
return (prop, _path) => {
|
|
771
|
+
return resolveFieldProp(prop, UI_FORM_FN_VALUE, META_DEFAULT, {
|
|
772
|
+
v: void 0,
|
|
773
|
+
data,
|
|
774
|
+
context,
|
|
775
|
+
entry: void 0
|
|
776
|
+
}, { transform: (raw) => parseStaticDefault(raw, prop) });
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Type-appropriate "defined-but-empty" fallback for primitive design types
|
|
781
|
+
* whose structural default in atscript's `finalDefault` table is
|
|
782
|
+
* `undefined`. Without this, `createFormData` on an optional `decimal`
|
|
783
|
+
* field (or any other primitive missed by atscript's table) returns
|
|
784
|
+
* `undefined` — but `createFormData` is called from "explicit add"
|
|
785
|
+
* contexts (optional-toggle, array-add, tuple-pad, union-pick) where the
|
|
786
|
+
* caller's intent is "give me a value the renderer can edit", not "no
|
|
787
|
+
* value yet". Returning `undefined` leaves the empty-state placeholder
|
|
788
|
+
* stuck in AsFieldShell.
|
|
789
|
+
*
|
|
790
|
+
* `decimal → "0"` mirrors the `number → 0` default — the atscript runtime
|
|
791
|
+
* validator (≥ 0.1.54) rejects `""` for decimal fields, so committing the
|
|
792
|
+
* canonical zero is the only init that survives a submit-without-edit.
|
|
793
|
+
* `useAsDecimal` pads the display to the field's effective scale, so the
|
|
794
|
+
* user sees `0.00` / `0.000` per the `@db.column.precision` annotation.
|
|
795
|
+
*/
|
|
796
|
+
function primitiveInitFallback(prop) {
|
|
797
|
+
if (prop.type.kind !== "") return void 0;
|
|
798
|
+
switch (prop.type.designType) {
|
|
799
|
+
case "decimal": return "0";
|
|
800
|
+
default: return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function createFormData(type, resolver) {
|
|
804
|
+
let value = createDataFromAnnotatedType(type, { mode: resolver ?? defaultValueResolver });
|
|
805
|
+
if (value === void 0) value = primitiveInitFallback(type);
|
|
806
|
+
return { value };
|
|
807
|
+
}
|
|
808
|
+
const variantValidatorCache = /* @__PURE__ */ new WeakMap();
|
|
809
|
+
function getVariantValidator(variant) {
|
|
810
|
+
let v = variantValidatorCache.get(variant.type);
|
|
811
|
+
if (!v) {
|
|
812
|
+
v = variant.type.validator();
|
|
813
|
+
variantValidatorCache.set(variant.type, v);
|
|
814
|
+
}
|
|
815
|
+
return v;
|
|
816
|
+
}
|
|
817
|
+
const variantsDiscriminatorCache = /* @__PURE__ */ new WeakMap();
|
|
818
|
+
function getVariantsDiscriminator(variants) {
|
|
819
|
+
let cached = variantsDiscriminatorCache.get(variants);
|
|
820
|
+
if (cached === void 0) {
|
|
821
|
+
cached = detectDiscriminator(variants.map((v) => v.type));
|
|
822
|
+
variantsDiscriminatorCache.set(variants, cached);
|
|
823
|
+
}
|
|
824
|
+
return cached;
|
|
825
|
+
}
|
|
826
|
+
function detectUnionVariant(value, variants) {
|
|
827
|
+
if (variants.length <= 1) return 0;
|
|
828
|
+
const disc = getVariantsDiscriminator(variants);
|
|
829
|
+
if (disc && value !== null && typeof value === "object") {
|
|
830
|
+
const tag = value[disc.propertyName];
|
|
831
|
+
const idx = disc.indexMapping[String(tag)];
|
|
832
|
+
if (idx !== void 0) return idx;
|
|
833
|
+
}
|
|
834
|
+
for (let i = 0; i < variants.length; i++) try {
|
|
835
|
+
if (getVariantValidator(variants[i]).validate(value, true)) return i;
|
|
836
|
+
} catch {}
|
|
837
|
+
return 0;
|
|
838
|
+
}
|
|
839
|
+
//#endregion
|
|
840
|
+
//#region src/form/validate.ts
|
|
841
|
+
let defaultValidatorPlugins = [];
|
|
842
|
+
/** Replace the default validator plugins applied to every form/field validator. */
|
|
843
|
+
function setDefaultValidatorPlugins(plugins) {
|
|
844
|
+
defaultValidatorPlugins = plugins;
|
|
845
|
+
}
|
|
846
|
+
/** Get the currently registered default validator plugins. */
|
|
847
|
+
function getDefaultValidatorPlugins() {
|
|
848
|
+
return defaultValidatorPlugins;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Returns a reusable validator function for a whole FormDef.
|
|
852
|
+
*
|
|
853
|
+
* Validator is created once and reused on every call.
|
|
854
|
+
* ATScript's @expect.* validation runs automatically.
|
|
855
|
+
* For custom `ui.fn.*` validators, install ui-fns and pass its plugin via `opts.plugins`.
|
|
856
|
+
*/
|
|
857
|
+
function getFormValidator(def, opts) {
|
|
858
|
+
const validator = new Validator(def.type, {
|
|
859
|
+
unknownProps: "ignore",
|
|
860
|
+
...opts,
|
|
861
|
+
plugins: [...defaultValidatorPlugins, ...opts?.plugins ?? []]
|
|
862
|
+
});
|
|
863
|
+
return (callOpts) => {
|
|
864
|
+
if (validator.validate(callOpts.data, true, {
|
|
865
|
+
data: callOpts.data,
|
|
866
|
+
context: callOpts.context ?? {}
|
|
867
|
+
})) return {};
|
|
868
|
+
const errors = {};
|
|
869
|
+
for (const err of validator.errors) errors[err.path] = err.message;
|
|
870
|
+
return errors;
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Creates a cached validator function for a single ATScript prop.
|
|
875
|
+
*
|
|
876
|
+
* The `Validator` instance is created lazily on first call and reused.
|
|
877
|
+
* Returns `true` when valid, or the first error message string when invalid.
|
|
878
|
+
*/
|
|
879
|
+
function createFieldValidator(prop, opts) {
|
|
880
|
+
let cached;
|
|
881
|
+
return (value, externalCtx) => {
|
|
882
|
+
cached ??= new Validator(prop, { plugins: defaultValidatorPlugins });
|
|
883
|
+
if (!cached.validate(value, true, externalCtx)) {
|
|
884
|
+
if (opts?.rootOnly) {
|
|
885
|
+
const rootError = cached.errors?.find((e) => e.path === "");
|
|
886
|
+
if (rootError) return rootError.message;
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
return cached.errors?.[0]?.message || "Invalid value";
|
|
890
|
+
}
|
|
891
|
+
return true;
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/form/error-utils.ts
|
|
896
|
+
/**
|
|
897
|
+
* Framework-agnostic helpers for working with form-error maps keyed by
|
|
898
|
+
* dotted path. Used by AsForm to drive error badges and auto-open
|
|
899
|
+
* collapsed sections; safe to share with React (or any other) bindings.
|
|
900
|
+
*
|
|
901
|
+
* Convention:
|
|
902
|
+
* - Keys are dotted paths (`a.b.c`); empty string and `__form` denote
|
|
903
|
+
* the form-level error.
|
|
904
|
+
* - Values may be `string | undefined`; falsy entries are dropped on
|
|
905
|
+
* merge.
|
|
906
|
+
*/
|
|
907
|
+
const FORM_ERROR_KEY = "__form";
|
|
908
|
+
/**
|
|
909
|
+
* Merge any number of partial error maps into a single dense
|
|
910
|
+
* `Record<path, message>`. Falsy values are skipped — later sources do
|
|
911
|
+
* NOT overwrite earlier ones with an empty value.
|
|
912
|
+
*/
|
|
913
|
+
function mergeErrorMaps(...maps) {
|
|
914
|
+
const merged = {};
|
|
915
|
+
for (const m of maps) {
|
|
916
|
+
if (!m) continue;
|
|
917
|
+
for (const k of Object.keys(m)) {
|
|
918
|
+
const v = m[k];
|
|
919
|
+
if (v) merged[k] = v;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return merged;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Yield every ancestor prefix of a dotted path, longest-first
|
|
926
|
+
* (`a.b.c` → `a.b.c`, `a.b`, `a`). Returns the path itself first so
|
|
927
|
+
* callers can include it in the iteration without a special case.
|
|
928
|
+
*
|
|
929
|
+
* Empty paths and the form-level key (`__form`) yield nothing.
|
|
930
|
+
*/
|
|
931
|
+
function* iteratePathAncestors(path) {
|
|
932
|
+
if (!path || path === FORM_ERROR_KEY) return;
|
|
933
|
+
let pos = path.length;
|
|
934
|
+
while (pos > 0) {
|
|
935
|
+
yield path.slice(0, pos);
|
|
936
|
+
const dot = path.lastIndexOf(".", pos - 1);
|
|
937
|
+
if (dot < 0) return;
|
|
938
|
+
pos = dot;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Build an indexed `Map<absolutePath, descendantErrorCount>` so each
|
|
943
|
+
* struct in the tree can render an error-count badge in O(1).
|
|
944
|
+
*
|
|
945
|
+
* For every error path, the count is incremented on the path itself
|
|
946
|
+
* AND every dotted-path ancestor — so a struct at `a.b` reports the
|
|
947
|
+
* total of all errors at `a.b` or below.
|
|
948
|
+
*/
|
|
949
|
+
function buildDescendantErrorCounts(errors) {
|
|
950
|
+
const map = /* @__PURE__ */ new Map();
|
|
951
|
+
for (const errPath of Object.keys(errors)) {
|
|
952
|
+
if (!errors[errPath]) continue;
|
|
953
|
+
for (const prefix of iteratePathAncestors(errPath)) map.set(prefix, (map.get(prefix) ?? 0) + 1);
|
|
954
|
+
}
|
|
955
|
+
return map;
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/form/grid.ts
|
|
959
|
+
/** Grid layout parsing for `@ui.form.grid.colSpan` / `@ui.form.grid.rowSpan`. */
|
|
960
|
+
const DEFAULT_COL_SPAN = 12;
|
|
961
|
+
const DEFAULT_ROW_SPAN = 1;
|
|
962
|
+
const COL_SPAN_ALIASES = {
|
|
963
|
+
full: 12,
|
|
964
|
+
half: 6,
|
|
965
|
+
third: 4
|
|
966
|
+
};
|
|
967
|
+
/** Accepts "1"-"12" and the aliases "full" (12), "half" (6), "third" (4). */
|
|
968
|
+
function parseColSpan(raw) {
|
|
969
|
+
if (!raw) return void 0;
|
|
970
|
+
const aliased = COL_SPAN_ALIASES[raw];
|
|
971
|
+
if (aliased !== void 0) return aliased;
|
|
972
|
+
const n = Number.parseInt(raw, 10);
|
|
973
|
+
if (Number.isFinite(n) && n >= 1 && n <= 12 && String(n) === raw) return n;
|
|
974
|
+
}
|
|
975
|
+
/** Accepts numeric strings "1"+; rejects "0", negatives, decimals, aliases. */
|
|
976
|
+
function parseRowSpan(raw) {
|
|
977
|
+
if (!raw) return void 0;
|
|
978
|
+
const n = Number.parseInt(raw, 10);
|
|
979
|
+
if (Number.isFinite(n) && n >= 1 && String(n) === raw) return n;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Resolve a field's grid footprint. Narrow defaults to full-width / single-row
|
|
983
|
+
* regardless of the desktop value, so authors can opt into a narrow override
|
|
984
|
+
* via the second annotation arg without it inheriting an unintended desktop span.
|
|
985
|
+
*/
|
|
986
|
+
function resolveGridSpec(colSpan, rowSpan) {
|
|
987
|
+
return {
|
|
988
|
+
col: {
|
|
989
|
+
desktop: parseColSpan(colSpan?.desktop) ?? 12,
|
|
990
|
+
narrow: parseColSpan(colSpan?.narrow) ?? 12
|
|
991
|
+
},
|
|
992
|
+
row: {
|
|
993
|
+
desktop: parseRowSpan(rowSpan?.desktop) ?? 1,
|
|
994
|
+
narrow: parseRowSpan(rowSpan?.narrow) ?? 1
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Build the UnoCSS class string for a field's grid footprint.
|
|
1000
|
+
*
|
|
1001
|
+
* - Skips desktop classes that match the default (`as-grid-item` already
|
|
1002
|
+
* covers `col-span-full row-span-1`).
|
|
1003
|
+
* - Skips narrow overrides that match the desktop value (no override needed).
|
|
1004
|
+
* - The narrow variant uses the custom `as-narrow:` prefix, which the
|
|
1005
|
+
* atscript-ui UnoCSS preset rewrites to `@container as-grid (max-width:
|
|
1006
|
+
* 480px) { ... }`. The parent grid is registered as
|
|
1007
|
+
* `container-name: as-grid` via the `as-form-grid` shortcut, so the
|
|
1008
|
+
* rule resolves against the actual grid's inline size — not the viewport.
|
|
1009
|
+
*
|
|
1010
|
+
* Returned string is space-separated, ready to drop into a Vue class binding.
|
|
1011
|
+
*/
|
|
1012
|
+
function buildGridClasses(spec) {
|
|
1013
|
+
const out = [];
|
|
1014
|
+
if (spec.col.desktop !== 12) out.push(`col-span-${spec.col.desktop}`);
|
|
1015
|
+
if (spec.row.desktop !== 1) out.push(`row-span-${spec.row.desktop}`);
|
|
1016
|
+
if (spec.col.narrow !== spec.col.desktop) out.push(`as-narrow:col-span-${spec.col.narrow}`);
|
|
1017
|
+
if (spec.row.narrow !== spec.row.desktop) out.push(`as-narrow:row-span-${spec.row.narrow}`);
|
|
1018
|
+
return out.join(" ");
|
|
1019
|
+
}
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/form/labels.ts
|
|
1022
|
+
/** Singular label for an array field (used by AsArray for "Add <singular>"). */
|
|
1023
|
+
function resolveSingularLabel(meta) {
|
|
1024
|
+
if (!meta) return "item";
|
|
1025
|
+
return getFieldMeta(meta, "ui.form.label.singular") || "item";
|
|
1026
|
+
}
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/form/measurement.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* Read measurement annotations off a single field prop. Returns `undefined`
|
|
1031
|
+
* for any annotation that's absent — callers can spread the result into a
|
|
1032
|
+
* larger record.
|
|
1033
|
+
*/
|
|
1034
|
+
function extractMeasurement(prop) {
|
|
1035
|
+
const precisionMeta = getFieldMeta(prop, DB_COLUMN_PRECISION);
|
|
1036
|
+
return {
|
|
1037
|
+
currencyCode: getFieldMeta(prop, DB_AMOUNT_CURRENCY),
|
|
1038
|
+
currencyRefField: getFieldMeta(prop, DB_AMOUNT_CURRENCY_REF),
|
|
1039
|
+
unitCode: getFieldMeta(prop, DB_UNIT),
|
|
1040
|
+
unitRefField: getFieldMeta(prop, DB_UNIT_REF),
|
|
1041
|
+
precisionScale: precisionMeta?.scale
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/form/decimal-format.ts
|
|
1046
|
+
const WHITESPACE_GROUP_RE = /[ ]/g;
|
|
1047
|
+
const numberFormatCache = /* @__PURE__ */ new Map();
|
|
1048
|
+
function getNumberFormat(locale, opts) {
|
|
1049
|
+
const key = `${locale ?? ""}|${opts.style ?? ""}|${opts.currency ?? ""}|${opts.currencyDisplay ?? ""}|${opts.useGrouping ?? ""}|${opts.minimumFractionDigits ?? ""}|${opts.maximumFractionDigits ?? ""}`;
|
|
1050
|
+
let f = numberFormatCache.get(key);
|
|
1051
|
+
if (!f) {
|
|
1052
|
+
f = new Intl.NumberFormat(locale, opts);
|
|
1053
|
+
numberFormatCache.set(key, f);
|
|
1054
|
+
}
|
|
1055
|
+
return f;
|
|
1056
|
+
}
|
|
1057
|
+
const decimalSeparatorCache = /* @__PURE__ */ new Map();
|
|
1058
|
+
const thousandsSeparatorCache = /* @__PURE__ */ new Map();
|
|
1059
|
+
const currencyDisplayCache = /* @__PURE__ */ new Map();
|
|
1060
|
+
const currencyDecimalsCache = /* @__PURE__ */ new Map();
|
|
1061
|
+
/** "." in en-US, "," in fr-FR. Returns "." when locale is undefined. */
|
|
1062
|
+
function getDecimalSeparator(locale) {
|
|
1063
|
+
const key = locale ?? "";
|
|
1064
|
+
const cached = decimalSeparatorCache.get(key);
|
|
1065
|
+
if (cached !== void 0) return cached;
|
|
1066
|
+
let out = ".";
|
|
1067
|
+
try {
|
|
1068
|
+
out = getNumberFormat(locale, { useGrouping: false }).formatToParts(1.1).find((p) => p.type === "decimal")?.value ?? ".";
|
|
1069
|
+
} catch {}
|
|
1070
|
+
decimalSeparatorCache.set(key, out);
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* "," in en-US, NNBSP (U+202F) in fr-FR. Returns "" when no grouping is
|
|
1075
|
+
* applied (or when Intl rejects the locale).
|
|
1076
|
+
*/
|
|
1077
|
+
function getThousandsSeparator(locale) {
|
|
1078
|
+
const key = locale ?? "";
|
|
1079
|
+
const cached = thousandsSeparatorCache.get(key);
|
|
1080
|
+
if (cached !== void 0) return cached;
|
|
1081
|
+
let out = "";
|
|
1082
|
+
try {
|
|
1083
|
+
out = getNumberFormat(locale, { useGrouping: true }).formatToParts(1e6).find((p) => p.type === "group")?.value ?? "";
|
|
1084
|
+
} catch {}
|
|
1085
|
+
thousandsSeparatorCache.set(key, out);
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
function getCurrencyDisplayParts(code, locale) {
|
|
1089
|
+
const key = `${locale ?? ""}|${code}`;
|
|
1090
|
+
const cached = currencyDisplayCache.get(key);
|
|
1091
|
+
if (cached) return cached;
|
|
1092
|
+
let out = {
|
|
1093
|
+
symbol: code,
|
|
1094
|
+
position: "prefix"
|
|
1095
|
+
};
|
|
1096
|
+
try {
|
|
1097
|
+
const parts = getNumberFormat(locale, {
|
|
1098
|
+
style: "currency",
|
|
1099
|
+
currency: code,
|
|
1100
|
+
currencyDisplay: "narrowSymbol"
|
|
1101
|
+
}).formatToParts(0);
|
|
1102
|
+
const idxCur = parts.findIndex((p) => p.type === "currency");
|
|
1103
|
+
const idxInt = parts.findIndex((p) => p.type === "integer");
|
|
1104
|
+
out = {
|
|
1105
|
+
symbol: parts[idxCur]?.value ?? code,
|
|
1106
|
+
position: idxInt === -1 || idxCur < idxInt ? "prefix" : "suffix"
|
|
1107
|
+
};
|
|
1108
|
+
} catch {}
|
|
1109
|
+
currencyDisplayCache.set(key, out);
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Currency's natural decimal count via Intl. JPY=0, USD/EUR=2, BHD/KWD=3.
|
|
1114
|
+
* Returns `undefined` for codes Intl doesn't know — caller falls back to
|
|
1115
|
+
* `dbPrecisionScale`.
|
|
1116
|
+
*/
|
|
1117
|
+
function getCurrencyDecimals(code, locale) {
|
|
1118
|
+
if (!code) return void 0;
|
|
1119
|
+
const key = `${locale ?? ""}|${code}`;
|
|
1120
|
+
if (currencyDecimalsCache.has(key)) return currencyDecimalsCache.get(key);
|
|
1121
|
+
let out;
|
|
1122
|
+
try {
|
|
1123
|
+
const max = getNumberFormat(locale, {
|
|
1124
|
+
style: "currency",
|
|
1125
|
+
currency: code
|
|
1126
|
+
}).resolvedOptions().maximumFractionDigits;
|
|
1127
|
+
out = typeof max === "number" ? max : void 0;
|
|
1128
|
+
} catch {
|
|
1129
|
+
out = void 0;
|
|
1130
|
+
}
|
|
1131
|
+
currencyDecimalsCache.set(key, out);
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Parse a user-typed decimal string. Returns the canonical decimal
|
|
1136
|
+
* (no thousands separator, "." as decimal separator) or `null` if invalid.
|
|
1137
|
+
* Accepts both "." and "," as the decimal separator (locale-aware).
|
|
1138
|
+
* Strips the locale thousands separator. Preserves sign. Does NOT
|
|
1139
|
+
* enforce scale.
|
|
1140
|
+
*/
|
|
1141
|
+
function parseDecimalInput(raw, locale) {
|
|
1142
|
+
if (typeof raw !== "string") return null;
|
|
1143
|
+
const trimmed = raw.trim();
|
|
1144
|
+
if (trimmed === "") return null;
|
|
1145
|
+
const decSep = getDecimalSeparator(locale);
|
|
1146
|
+
const thouSep = getThousandsSeparator(locale);
|
|
1147
|
+
let sign = "";
|
|
1148
|
+
let body = trimmed;
|
|
1149
|
+
if (body.startsWith("-")) {
|
|
1150
|
+
sign = "-";
|
|
1151
|
+
body = body.slice(1);
|
|
1152
|
+
} else if (body.startsWith("+")) body = body.slice(1);
|
|
1153
|
+
if (body.length === 0) return null;
|
|
1154
|
+
if (body.includes("-") || body.includes("+")) return null;
|
|
1155
|
+
if (thouSep && body.includes(thouSep)) {
|
|
1156
|
+
const decIdx = body.indexOf(decSep);
|
|
1157
|
+
const headRaw = decIdx === -1 ? body : body.slice(0, decIdx);
|
|
1158
|
+
const tail = decIdx === -1 ? "" : body.slice(decIdx);
|
|
1159
|
+
if (tail.includes(thouSep)) return null;
|
|
1160
|
+
if (headRaw.includes(thouSep)) {
|
|
1161
|
+
const groups = headRaw.split(thouSep);
|
|
1162
|
+
const first = groups[0];
|
|
1163
|
+
if (first.length < 1 || first.length > 3 || !/^\d+$/.test(first)) return null;
|
|
1164
|
+
for (let i = 1; i < groups.length; i += 1) if (!/^\d{3}$/.test(groups[i])) return null;
|
|
1165
|
+
}
|
|
1166
|
+
body = `${headRaw.split(thouSep).join("")}${tail}`;
|
|
1167
|
+
}
|
|
1168
|
+
body = body.replace(WHITESPACE_GROUP_RE, "");
|
|
1169
|
+
if (decSep !== ".") {
|
|
1170
|
+
if ((body.match(/,/g)?.length ?? 0) > 1) return null;
|
|
1171
|
+
if (body.includes(".") && body.includes(",")) return null;
|
|
1172
|
+
if (body.includes(",")) body = body.replace(",", ".");
|
|
1173
|
+
} else if (body.includes(",")) {
|
|
1174
|
+
if (body.includes(".")) return null;
|
|
1175
|
+
if ((body.match(/,/g)?.length ?? 0) > 1) return null;
|
|
1176
|
+
body = body.replace(",", ".");
|
|
1177
|
+
}
|
|
1178
|
+
if (!/^\d*\.?\d*$/.test(body)) return null;
|
|
1179
|
+
if (body === "" || body === ".") return null;
|
|
1180
|
+
if (body.endsWith(".")) body = body.slice(0, -1);
|
|
1181
|
+
if (body.startsWith(".")) body = `0${body}`;
|
|
1182
|
+
const dot = body.indexOf(".");
|
|
1183
|
+
if (dot === -1) body = body.replace(/^0+(\d)/, "$1");
|
|
1184
|
+
else {
|
|
1185
|
+
const head = body.slice(0, dot);
|
|
1186
|
+
const tail = body.slice(dot);
|
|
1187
|
+
body = `${head.replace(/^0+(\d)/, "$1")}${tail}`;
|
|
1188
|
+
}
|
|
1189
|
+
if (sign === "-" && /^0+(\.0+)?$/.test(body)) sign = "";
|
|
1190
|
+
return `${sign}${body}`;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Enforce a fractional-digit count by truncating or padding. String-only —
|
|
1194
|
+
* no float arithmetic. Default behaviour truncates (no rounding) so digits
|
|
1195
|
+
* the user typed can't silently shift. Pass `roundHalfUp: true` to round.
|
|
1196
|
+
*
|
|
1197
|
+
* enforceScale("12.345", 2) → "12.34" (truncate)
|
|
1198
|
+
* enforceScale("12.3", 4) → "12.3000" (pad)
|
|
1199
|
+
* enforceScale("12", 0) → "12"
|
|
1200
|
+
* enforceScale("12.99", 0) → "12" (no rounding)
|
|
1201
|
+
*/
|
|
1202
|
+
function enforceScale(s, scale, opts) {
|
|
1203
|
+
if (typeof s !== "string" || s === "") return s;
|
|
1204
|
+
if (scale === void 0 || scale < 0) return s;
|
|
1205
|
+
const parts = splitDecimalString(s);
|
|
1206
|
+
if (scale === 0) {
|
|
1207
|
+
if (opts?.roundHalfUp && parts.decimal.length > 0 && parts.decimal[0] >= "5") {
|
|
1208
|
+
const bumped = addOne(parts.integer);
|
|
1209
|
+
return joinDecimalString({
|
|
1210
|
+
sign: parts.sign,
|
|
1211
|
+
integer: bumped,
|
|
1212
|
+
decimal: ""
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
return joinDecimalString({
|
|
1216
|
+
sign: parts.sign,
|
|
1217
|
+
integer: parts.integer,
|
|
1218
|
+
decimal: ""
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (parts.decimal.length === scale) return joinDecimalString(parts);
|
|
1222
|
+
if (parts.decimal.length < scale) return joinDecimalString({
|
|
1223
|
+
sign: parts.sign,
|
|
1224
|
+
integer: parts.integer,
|
|
1225
|
+
decimal: parts.decimal.padEnd(scale, "0")
|
|
1226
|
+
});
|
|
1227
|
+
let head = parts.decimal.slice(0, scale);
|
|
1228
|
+
const dropped = parts.decimal.slice(scale);
|
|
1229
|
+
let integer = parts.integer;
|
|
1230
|
+
if (opts?.roundHalfUp && dropped.length > 0 && dropped[0] >= "5") {
|
|
1231
|
+
const bumpedFrac = addOne(head);
|
|
1232
|
+
if (bumpedFrac.length > head.length) {
|
|
1233
|
+
integer = addOne(integer);
|
|
1234
|
+
head = "0".repeat(scale);
|
|
1235
|
+
} else head = bumpedFrac;
|
|
1236
|
+
}
|
|
1237
|
+
return joinDecimalString({
|
|
1238
|
+
sign: parts.sign,
|
|
1239
|
+
integer,
|
|
1240
|
+
decimal: head
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
function addOne(digits) {
|
|
1244
|
+
if (digits === "") return "1";
|
|
1245
|
+
const out = digits.split("");
|
|
1246
|
+
let i = out.length - 1;
|
|
1247
|
+
let carry = 1;
|
|
1248
|
+
while (i >= 0 && carry > 0) {
|
|
1249
|
+
const d = out[i].charCodeAt(0) - 48 + carry | 0;
|
|
1250
|
+
if (d >= 10) {
|
|
1251
|
+
out[i] = "0";
|
|
1252
|
+
carry = 1;
|
|
1253
|
+
} else {
|
|
1254
|
+
out[i] = String.fromCharCode(d + 48);
|
|
1255
|
+
carry = 0;
|
|
1256
|
+
}
|
|
1257
|
+
i -= 1;
|
|
1258
|
+
}
|
|
1259
|
+
if (carry > 0) out.unshift("1");
|
|
1260
|
+
return out.join("");
|
|
1261
|
+
}
|
|
1262
|
+
function splitDecimalString(s) {
|
|
1263
|
+
if (typeof s !== "string" || s === "") return {
|
|
1264
|
+
sign: "",
|
|
1265
|
+
integer: "0",
|
|
1266
|
+
decimal: ""
|
|
1267
|
+
};
|
|
1268
|
+
let sign = "";
|
|
1269
|
+
let body = s;
|
|
1270
|
+
if (body.startsWith("-")) {
|
|
1271
|
+
sign = "-";
|
|
1272
|
+
body = body.slice(1);
|
|
1273
|
+
} else if (body.startsWith("+")) body = body.slice(1);
|
|
1274
|
+
if (body === "") return {
|
|
1275
|
+
sign: "",
|
|
1276
|
+
integer: "0",
|
|
1277
|
+
decimal: ""
|
|
1278
|
+
};
|
|
1279
|
+
const dot = body.indexOf(".");
|
|
1280
|
+
let integer;
|
|
1281
|
+
let decimal;
|
|
1282
|
+
if (dot === -1) {
|
|
1283
|
+
integer = body;
|
|
1284
|
+
decimal = "";
|
|
1285
|
+
} else {
|
|
1286
|
+
integer = body.slice(0, dot);
|
|
1287
|
+
decimal = body.slice(dot + 1);
|
|
1288
|
+
}
|
|
1289
|
+
if (integer === "") integer = "0";
|
|
1290
|
+
integer = integer.replace(/^0+(\d)/, "$1");
|
|
1291
|
+
if (sign === "-" && integer === "0" && !decimal.replace(/0/g, "")) sign = "";
|
|
1292
|
+
return {
|
|
1293
|
+
sign,
|
|
1294
|
+
integer,
|
|
1295
|
+
decimal
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function joinDecimalString(parts) {
|
|
1299
|
+
const integer = parts.integer === "" ? "0" : parts.integer;
|
|
1300
|
+
const decimal = parts.decimal;
|
|
1301
|
+
const body = decimal === "" ? integer : `${integer}.${decimal}`;
|
|
1302
|
+
return `${parts.sign}${body}`;
|
|
1303
|
+
}
|
|
1304
|
+
function formatDecimalForDisplay(opts) {
|
|
1305
|
+
const { value, scale, locale, currency, unit, useGrouping = true } = opts;
|
|
1306
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
1307
|
+
let canonical;
|
|
1308
|
+
if (typeof value === "number") {
|
|
1309
|
+
if (!Number.isFinite(value)) return "";
|
|
1310
|
+
canonical = String(value);
|
|
1311
|
+
} else canonical = value;
|
|
1312
|
+
if (!/^[+-]?\d*(\.\d*)?$/.test(canonical) || canonical === "." || canonical === "-") return canonical;
|
|
1313
|
+
if (scale !== void 0 && scale >= 0) canonical = enforceScale(canonical, scale);
|
|
1314
|
+
const parts = splitDecimalString(canonical);
|
|
1315
|
+
const asNumber = Number(canonical);
|
|
1316
|
+
const intlSafe = Number.isFinite(asNumber);
|
|
1317
|
+
if (currency) {
|
|
1318
|
+
try {
|
|
1319
|
+
const fmt = getNumberFormat(locale, {
|
|
1320
|
+
style: "currency",
|
|
1321
|
+
currency,
|
|
1322
|
+
currencyDisplay: "narrowSymbol",
|
|
1323
|
+
minimumFractionDigits: scale,
|
|
1324
|
+
maximumFractionDigits: scale,
|
|
1325
|
+
useGrouping
|
|
1326
|
+
});
|
|
1327
|
+
if (intlSafe) return fmt.format(asNumber);
|
|
1328
|
+
} catch {}
|
|
1329
|
+
return `${currency} ${formatPlain(parts, scale, locale, useGrouping)}`;
|
|
1330
|
+
}
|
|
1331
|
+
const base = formatPlain(parts, scale, locale, useGrouping);
|
|
1332
|
+
return unit ? `${base} ${unit}` : base;
|
|
1333
|
+
}
|
|
1334
|
+
function formatPlain(parts, scale, locale, useGrouping) {
|
|
1335
|
+
const intStr = useGrouping ? groupInteger(parts.integer, locale) : parts.integer;
|
|
1336
|
+
const decStr = scale !== void 0 && scale >= 0 ? parts.decimal.padEnd(scale, "0").slice(0, scale) : parts.decimal;
|
|
1337
|
+
const decSep = getDecimalSeparator(locale);
|
|
1338
|
+
const body = decStr.length > 0 ? `${intStr}${decSep}${decStr}` : intStr;
|
|
1339
|
+
return `${parts.sign}${body}`;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Insert the locale's thousands separator into a plain integer string.
|
|
1343
|
+
* Pure string-based — no float math, handles arbitrary length.
|
|
1344
|
+
*/
|
|
1345
|
+
function groupInteger(integer, locale) {
|
|
1346
|
+
const sep = getThousandsSeparator(locale);
|
|
1347
|
+
if (!sep) return integer;
|
|
1348
|
+
if (integer.length <= 3) return integer;
|
|
1349
|
+
const out = [];
|
|
1350
|
+
let i = integer.length;
|
|
1351
|
+
while (i > 3) {
|
|
1352
|
+
out.unshift(integer.slice(i - 3, i));
|
|
1353
|
+
i -= 3;
|
|
1354
|
+
}
|
|
1355
|
+
out.unshift(integer.slice(0, i));
|
|
1356
|
+
return out.join(sep);
|
|
1357
|
+
}
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region src/table/create-table-def.ts
|
|
1360
|
+
/**
|
|
1361
|
+
* Builds a TableDef from a moost-db MetaResponse.
|
|
1362
|
+
*
|
|
1363
|
+
* 1. Deserializes `meta.type` into a live TAtscriptAnnotatedType
|
|
1364
|
+
* 2. Flattens to discover all field paths
|
|
1365
|
+
* 3. Builds ColumnDef per field using annotations + meta.fields capabilities
|
|
1366
|
+
* 4. Sorts by @ui.table.order
|
|
1367
|
+
*/
|
|
1368
|
+
function createTableDef(meta, preDeserializedType) {
|
|
1369
|
+
const type = preDeserializedType ?? deserializeAnnotatedType(meta.type);
|
|
1370
|
+
const flatMap = type.type.kind === "object" ? flattenAnnotatedType(type, { excludePhantomTypes: true }) : /* @__PURE__ */ new Map();
|
|
1371
|
+
const columns = [];
|
|
1372
|
+
for (const [path, prop] of flatMap.entries()) {
|
|
1373
|
+
if (path === "") continue;
|
|
1374
|
+
if (!(path in meta.fields)) {
|
|
1375
|
+
const kind = prop.type.kind;
|
|
1376
|
+
if (path.includes(".") || kind === "object" || kind === "array") continue;
|
|
1377
|
+
}
|
|
1378
|
+
const fieldMeta = meta.fields[path];
|
|
1379
|
+
const options = extractLiteralOptions(prop);
|
|
1380
|
+
const valueHelpInfo = extractValueHelp(prop);
|
|
1381
|
+
const maxLengthMeta = getFieldMeta(prop, EXPECT_MAX_LENGTH);
|
|
1382
|
+
const tableType = getFieldMeta(prop, UI_TABLE_TYPE);
|
|
1383
|
+
const sharedType = getFieldMeta(prop, UI_TYPE);
|
|
1384
|
+
const tableComponent = getFieldMeta(prop, UI_TABLE_COMPONENT);
|
|
1385
|
+
columns.push({
|
|
1386
|
+
path,
|
|
1387
|
+
label: getFieldMeta(prop, "meta.label") ?? humanizePath(path),
|
|
1388
|
+
type: tableType ?? sharedType ?? (valueHelpInfo ? "ref" : inferDisplayType(prop, options)),
|
|
1389
|
+
component: tableComponent,
|
|
1390
|
+
sortable: fieldMeta?.sortable ?? false,
|
|
1391
|
+
filterable: fieldMeta?.filterable ?? false,
|
|
1392
|
+
nullable: prop.optional === true,
|
|
1393
|
+
visible: getFieldMeta(prop, UI_TABLE_HIDDEN) === void 0,
|
|
1394
|
+
width: getFieldMeta(prop, UI_TABLE_WIDTH),
|
|
1395
|
+
maxLen: maxLengthMeta?.length,
|
|
1396
|
+
order: getFieldMeta(prop, "ui.table.order") ?? Infinity,
|
|
1397
|
+
options,
|
|
1398
|
+
valueHelpInfo,
|
|
1399
|
+
...extractMeasurement(prop)
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
columns.sort((a, b) => a.order - b.order);
|
|
1403
|
+
const actions = groupActions(meta.actions ?? []);
|
|
1404
|
+
const crud = meta.crud ?? {};
|
|
1405
|
+
return {
|
|
1406
|
+
type,
|
|
1407
|
+
columns,
|
|
1408
|
+
flatMap,
|
|
1409
|
+
primaryKeys: meta.primaryKeys,
|
|
1410
|
+
preferredId: meta.preferredId ?? meta.primaryKeys,
|
|
1411
|
+
crud,
|
|
1412
|
+
canRemove: "remove" in crud,
|
|
1413
|
+
actions,
|
|
1414
|
+
searchable: meta.searchable,
|
|
1415
|
+
vectorSearchable: meta.vectorSearchable,
|
|
1416
|
+
searchIndexes: meta.searchIndexes,
|
|
1417
|
+
relations: meta.relations
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/** Sort by (order ?? 0). `toSorted` is stable per spec, so ties preserve declaration order. */
|
|
1421
|
+
function byOrder(xs) {
|
|
1422
|
+
return xs.toSorted((x, y) => (x.order ?? 0) - (y.order ?? 0));
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Partition actions by `level`, sort each group by `(order ?? 0)` then
|
|
1426
|
+
* declaration order, and pick the first `default: true` entry per level.
|
|
1427
|
+
* The synthesised `__remove` UI action lives outside this set and is never
|
|
1428
|
+
* a candidate for `default.row`.
|
|
1429
|
+
*/
|
|
1430
|
+
function groupActions(actions) {
|
|
1431
|
+
const table = [];
|
|
1432
|
+
const row = [];
|
|
1433
|
+
const rows = [];
|
|
1434
|
+
for (const a of actions) if (a.level === "table") table.push(a);
|
|
1435
|
+
else if (a.level === "row") row.push(a);
|
|
1436
|
+
else if (a.level === "rows") rows.push(a);
|
|
1437
|
+
const tableSorted = byOrder(table);
|
|
1438
|
+
const rowSorted = byOrder(row);
|
|
1439
|
+
const rowsSorted = byOrder(rows);
|
|
1440
|
+
return {
|
|
1441
|
+
table: tableSorted,
|
|
1442
|
+
row: rowSorted,
|
|
1443
|
+
rows: rowsSorted,
|
|
1444
|
+
default: {
|
|
1445
|
+
table: tableSorted.find((a) => a.default === true),
|
|
1446
|
+
row: rowSorted.find((a) => a.default === true),
|
|
1447
|
+
rows: rowsSorted.find((a) => a.default === true)
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
/** Infers a display type string from the annotated type's kind and designType. */
|
|
1452
|
+
function inferDisplayType(prop, literalOpts) {
|
|
1453
|
+
const kind = prop.type.kind;
|
|
1454
|
+
if (kind === "array") return "array";
|
|
1455
|
+
if (kind === "object") return "object";
|
|
1456
|
+
if (kind === "union") return literalOpts !== void 0 ? "enum" : "union";
|
|
1457
|
+
if (kind === "") {
|
|
1458
|
+
const final = prop.type;
|
|
1459
|
+
const dt = final.designType;
|
|
1460
|
+
if (dt === "number") return final.tags?.has("timestamp") ? "datetime" : "number";
|
|
1461
|
+
if (dt === "decimal") return "number";
|
|
1462
|
+
if (dt === "boolean") return "boolean";
|
|
1463
|
+
return "text";
|
|
1464
|
+
}
|
|
1465
|
+
return "text";
|
|
1466
|
+
}
|
|
1467
|
+
/** Converts a dot-path to a human-readable label (e.g. 'firstName' → 'First Name'). */
|
|
1468
|
+
function humanizePath(path) {
|
|
1469
|
+
return path.slice(path.lastIndexOf(".") + 1).replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (s) => s.toUpperCase());
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/shared/str.ts
|
|
1473
|
+
/** Safely convert an unknown value to a string without triggering no-base-to-string lint errors. */
|
|
1474
|
+
function str(value) {
|
|
1475
|
+
if (typeof value === "string") return value;
|
|
1476
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return `${value}`;
|
|
1477
|
+
return JSON.stringify(value) ?? "";
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/table/column-resolver.ts
|
|
1481
|
+
/** Get visible columns only, already sorted by order. */
|
|
1482
|
+
function getVisibleColumns(def) {
|
|
1483
|
+
return def.columns.filter((c) => c.visible);
|
|
1484
|
+
}
|
|
1485
|
+
/** Get sortable columns. */
|
|
1486
|
+
function getSortableColumns(def) {
|
|
1487
|
+
return def.columns.filter((c) => c.sortable);
|
|
1488
|
+
}
|
|
1489
|
+
/** Get filterable columns. */
|
|
1490
|
+
function getFilterableColumns(def) {
|
|
1491
|
+
return def.columns.filter((c) => c.filterable);
|
|
1492
|
+
}
|
|
1493
|
+
/** Find a column by path. */
|
|
1494
|
+
function getColumn(def, path) {
|
|
1495
|
+
return def.columns.find((c) => c.path === path);
|
|
1496
|
+
}
|
|
1497
|
+
//#endregion
|
|
1498
|
+
export { DB_AMOUNT_CURRENCY, DB_AMOUNT_CURRENCY_REF, DB_COLUMN_PRECISION, DB_HTTP_PATH, DB_REL_FK, DB_UNIT, DB_UNIT_REF, DEFAULT_COL_SPAN, DEFAULT_ROW_SPAN, EXPECT_MAX_LENGTH, META_DEFAULT, META_DESCRIPTION, META_ID, META_LABEL, META_READONLY, META_REQUIRED, META_SENSITIVE, StaticFieldResolver, UI_DICT_ATTR, UI_DICT_DESCR, UI_DICT_FILTERABLE, UI_DICT_LABEL, UI_DICT_SEARCHABLE, UI_DICT_SORTABLE, UI_FORM_ACTION, UI_FORM_ATTR, UI_FORM_AUTOCOMPLETE, UI_FORM_CLASSES, UI_FORM_COMPONENT, UI_FORM_DISABLED, UI_FORM_FN_ATTR, UI_FORM_FN_CLASSES, UI_FORM_FN_DESCRIPTION, UI_FORM_FN_DISABLED, UI_FORM_FN_HIDDEN, UI_FORM_FN_HINT, UI_FORM_FN_LABEL, UI_FORM_FN_OPTIONS, UI_FORM_FN_PLACEHOLDER, UI_FORM_FN_PREFIX, UI_FORM_FN_READONLY, UI_FORM_FN_STYLES, UI_FORM_FN_SUBMIT_DISABLED, UI_FORM_FN_SUBMIT_TEXT, UI_FORM_FN_TITLE, UI_FORM_FN_VALUE, UI_FORM_GRID_COL_SPAN, UI_FORM_GRID_ROW_SPAN, UI_FORM_HIDDEN, UI_FORM_HINT, UI_FORM_LABEL_SINGULAR, UI_FORM_OPTIONS, UI_FORM_ORDER, UI_FORM_PLACEHOLDER, UI_FORM_PREFIX, UI_FORM_PREFIX_ICON, UI_FORM_PREFIX_REF, UI_FORM_STYLES, UI_FORM_SUBMIT_TEXT, UI_FORM_SUFFIX, UI_FORM_SUFFIX_ICON, UI_FORM_SUFFIX_REF, UI_FORM_TYPE, UI_FORM_VALIDATE, UI_TABLE_ATTR, UI_TABLE_CLASSES, UI_TABLE_COMPONENT, UI_TABLE_FN_ATTR, UI_TABLE_FN_CLASSES, UI_TABLE_FN_PREFIX, UI_TABLE_FN_STYLES, UI_TABLE_HIDDEN, UI_TABLE_ORDER, UI_TABLE_STYLES, UI_TABLE_TYPE, UI_TABLE_WIDTH, UI_TYPE, ValueHelpClient, WF_ACTION_WITH_DATA, asArray, buildDescendantErrorCounts, buildGridClasses, buildUnionVariants, createFieldValidator, createFormData, createFormDef, createFormValueResolver, createTableDef, defaultResolver, detectUnionVariant, enforceScale, extractLiteralOptions, extractMeasurement, extractValueHelp, formatDecimalForDisplay, getByPath, getColumn, getCurrencyDecimals, getCurrencyDisplayParts, getDecimalSeparator, getDefaultClientFactory, getDefaultValidatorPlugins, getFieldMeta, getFilterableColumns, getFormValidator, getMetaEntry, getResolver, getSortableColumns, getThousandsSeparator, getVisibleColumns, groupInteger, hasComputedAnnotations, isArrayField, isObjectField, isPureLiteralUnion, isTupleField, isUnionField, iteratePathAncestors, joinDecimalString, mergeErrorMaps, optKey, optLabel, parseColSpan, parseDecimalInput, parseRowSpan, parseStaticAttrs, parseStaticOptions, resetDefaultClientFactory, resetMetaCache, resetValueHelpCache, resolveAttrs, resolveFieldProp, resolveFormProp, resolveGridSpec, resolveOptions, resolveSingularLabel, resolveStatic, resolveValueHelp, setByPath, setDefaultClientFactory, setDefaultValidatorPlugins, setResolver, splitDecimalString, str, valueHelpDictPaths };
|