@aerokit/sdk 14.66.0 → 14.68.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/package.json +1 -1
- package/test/api.js +2 -0
- package/test/flows/crud.js +67 -48
- package/test/flows/list.js +9 -2
- package/test/flows/multilingual.js +2 -1
- package/test/flows/my.js +2 -1
- package/test/flows/rest.js +2 -1
- package/test/form.js +15 -5
- package/test/sample-values.js +73 -9
package/package.json
CHANGED
package/test/api.js
CHANGED
|
@@ -19,6 +19,8 @@ export function makeApi(request, manifest) {
|
|
|
19
19
|
count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)),
|
|
20
20
|
get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson),
|
|
21
21
|
getResponse: (entity, id) => request.get(url(entity, '/' + id)),
|
|
22
|
+
// the generated controller's filtered read (what the list's filters POST): [{ propertyName, operator, value }]
|
|
23
|
+
search: (entity, conditions) => request.post(url(entity, '/search'), { data: { conditions } }).then(asJson),
|
|
22
24
|
create: (entity, data) => request.post(url(entity), { data }).then(asJson),
|
|
23
25
|
update: (entity, id, data) => request.put(url(entity, '/' + id), { data }).then(asJson),
|
|
24
26
|
// Answers whether the row is gone. A record whose process declares `whenDeleted: refuse` is
|
package/test/flows/crud.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
|
+
import { makeApi } from '../api.js';
|
|
2
3
|
import { fillField, fillForm, resolveRelationSamples } from '../form.js';
|
|
3
|
-
import { handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
4
5
|
|
|
5
6
|
// Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
|
|
6
7
|
// layout (manage-list, master-detail, document) and searching the string columns server-side.
|
|
@@ -14,6 +15,23 @@ function dataRow(page, text) {
|
|
|
14
15
|
return page.locator('tbody tr', { hasText: text });
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Edit and Delete of a row live in its "Row actions" menu - the one place every list layout
|
|
19
|
+
// (manage, document, master-detail) offers both; the manage list's record sheet puts Delete behind
|
|
20
|
+
// an overflow menu and its primary button may be a process task rather than Edit (#7491).
|
|
21
|
+
async function rowAction(page, text, action) {
|
|
22
|
+
await dataRow(page, text).getByRole('button', { name: 'Row actions' }).click();
|
|
23
|
+
await page.getByRole('menuitem', { name: action, exact: true }).click();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A walk that fails halfway leaves its APPTEST- row behind, and the next run's REST flow then hits
|
|
27
|
+
// a unique: column with it (409). Remove whatever the walk created - the edited name starts with
|
|
28
|
+
// the created one - unless the UI delete already did.
|
|
29
|
+
async function removeLeftovers(client, manifest, entity, handle, created) {
|
|
30
|
+
const idProperty = manifest.idProperty ?? 'Id';
|
|
31
|
+
const rows = await client.search(entity, [{ propertyName: handle.name, operator: 'LIKE', value: created + '%' }]);
|
|
32
|
+
for (const row of rows ?? []) await client.remove(entity, row[idProperty]);
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export function crudFlow(manifest, entity, opts = {}) {
|
|
18
36
|
const cfg = opts.extend?.entities?.[entity.name] ?? {};
|
|
19
37
|
const skip = new Set(cfg.skip ?? []);
|
|
@@ -26,59 +44,60 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
26
44
|
if (!handleField(entity)) return;
|
|
27
45
|
|
|
28
46
|
test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => {
|
|
29
|
-
const record = sampleRecord(entity);
|
|
47
|
+
const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
|
|
30
48
|
const handle = handleField(entity);
|
|
31
49
|
const relationSamples = await resolveRelationSamples(api, manifest, entity);
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await expect(page).toHaveURL(/\/create$/);
|
|
38
|
-
await cfg.beforeCreate?.(page, record);
|
|
39
|
-
await fillForm(page, manifest, entity, record, relationSamples, opts);
|
|
40
|
-
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
41
|
-
if (entity.layout === 'document') {
|
|
42
|
-
// a document create lands on the NEW record's page (line-item editing continues there);
|
|
43
|
-
// saving an edit is what returns to the list
|
|
44
|
-
await expect(page).toHaveURL(/\/edit$/);
|
|
51
|
+
const created = record[handle.name];
|
|
52
|
+
const listRoute = entity.route.replace(/[#/]/g, '\\$&');
|
|
53
|
+
try {
|
|
54
|
+
// create
|
|
45
55
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
46
|
-
|
|
47
|
-
await
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
// exact: the empty state adds a second "New <Entity>" button that a substring match also hits
|
|
57
|
+
await page.getByRole('button', { name: 'New', exact: true }).click();
|
|
58
|
+
await expect(page).toHaveURL(/\/create$/);
|
|
59
|
+
await cfg.beforeCreate?.(page, record);
|
|
60
|
+
await fillForm(page, manifest, entity, record, relationSamples, opts);
|
|
61
|
+
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
62
|
+
// A create lands on the NEW record's page when there is more to do there - a document's
|
|
63
|
+
// line items, a manage record's detail collections - and on the list otherwise. Saving an
|
|
64
|
+
// edit is what returns to the list.
|
|
65
|
+
const recordPage = entity.layout === 'document' ? '/[^/]+/edit' : '(/[^/]+/edit)?';
|
|
66
|
+
await expect(page).toHaveURL(new RegExp(listRoute + recordPage + '$'));
|
|
67
|
+
if (page.url().endsWith('/edit')) await page.goto(manifest.standaloneShell + entity.route);
|
|
68
|
+
await filterBy(page, created);
|
|
69
|
+
await expect(dataRow(page, created)).toHaveCount(1);
|
|
70
|
+
await cfg.afterCreate?.(page, record);
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
record[handle.name] = updated;
|
|
69
|
-
}
|
|
72
|
+
// edit
|
|
73
|
+
if (!skip.has('edit')) {
|
|
74
|
+
const updated = created + '-UPD';
|
|
75
|
+
await rowAction(page, created, 'Edit');
|
|
76
|
+
await expect(page).toHaveURL(/\/edit$/);
|
|
77
|
+
// the record loads async after the form renders - filling before the fetch completes
|
|
78
|
+
// gets overwritten by the load and Save persists the OLD value
|
|
79
|
+
await expect(page.locator('#f_' + handle.name)).toHaveValue(created);
|
|
80
|
+
await fillField(page, handle, updated, opts);
|
|
81
|
+
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
82
|
+
await expect(page).toHaveURL(new RegExp(listRoute + '$'));
|
|
83
|
+
await filterBy(page, updated);
|
|
84
|
+
await expect(dataRow(page, updated)).toHaveCount(1);
|
|
85
|
+
record[handle.name] = updated;
|
|
86
|
+
}
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
// delete (row menu, then the confirm dialog). An entity whose process declares
|
|
89
|
+
// `whenDeleted: refuse` answers the delete with an error toast while its instance runs - the
|
|
90
|
+
// create above started it - so the walk stops here and the REST flow asserts that refusal.
|
|
91
|
+
if (!skip.has('delete') && !entity.deleteGuardedByProcess?.length) {
|
|
92
|
+
await rowAction(page, record[handle.name], 'Delete');
|
|
93
|
+
const dialog = page.locator('[x-h-dialog-overlay][data-open]');
|
|
94
|
+
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
95
|
+
await expect(dialog).toHaveCount(0);
|
|
96
|
+
await filterBy(page, record[handle.name]);
|
|
97
|
+
await expect(dataRow(page, record[handle.name])).toHaveCount(0);
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await removeLeftovers(makeApi(api, manifest), manifest, entity, handle, created);
|
|
82
101
|
}
|
|
83
102
|
});
|
|
84
103
|
}
|
package/test/flows/list.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
2
|
import { labelOf } from '../sample-values.js';
|
|
3
3
|
|
|
4
|
-
// The
|
|
4
|
+
// The shell breadcrumb's current crumb - the one page element every list layout shares that names
|
|
5
|
+
// the entity (the sidebar label, which the manifest's labelPlural mirrors).
|
|
6
|
+
export function listCrumb(page, entity) {
|
|
7
|
+
return page.locator('[x-h-breadcrumb-page]', { hasText: entity.labelPlural }).first();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The list page renders under the shell breadcrumb naming it by its plural label (the #7491
|
|
11
|
+
// manage list carries no title of its own), then one of three bodies -
|
|
5
12
|
// a tree (hierarchy entities render role=treeitem nodes, no table), the table with one
|
|
6
13
|
// column header per major field, or (when the entity has no rows yet) the empty state.
|
|
7
14
|
// When seed data is expected, rows/nodes must actually be there.
|
|
8
15
|
export function listFlow(manifest, entity) {
|
|
9
16
|
test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
|
|
10
17
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
11
|
-
await expect(page
|
|
18
|
+
await expect(listCrumb(page, entity)).toBeVisible();
|
|
12
19
|
if (entity.hierarchy) {
|
|
13
20
|
if (entity.expectSeedData) {
|
|
14
21
|
await expect(page.getByRole('treeitem').first()).toBeVisible();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
|
+
import { listCrumb } from './list.js';
|
|
2
3
|
|
|
3
4
|
// The read-time translation overlay: switch the shared language key (what the Region &
|
|
4
5
|
// Language setting writes), reload, and a known seed row shows its translated name.
|
|
@@ -9,7 +10,7 @@ export function multilingualFlow(manifest, entity) {
|
|
|
9
10
|
|
|
10
11
|
test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
|
|
11
12
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
12
|
-
await expect(page
|
|
13
|
+
await expect(listCrumb(page, entity)).toBeVisible();
|
|
13
14
|
await page.evaluate((lang) => localStorage.setItem('codbex.harmonia.language', lang), sample.language);
|
|
14
15
|
await page.reload();
|
|
15
16
|
await expect(page.locator('tbody tr', { hasText: sample.translated }).first()).toBeVisible();
|
package/test/flows/my.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { makeApi } from '../api.js';
|
|
2
2
|
import { expect, test } from '../fixtures.js';
|
|
3
3
|
import { resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// Wave 2 (UI parity): the personal PAGE renders what the wire serves. Driven by the same
|
|
7
7
|
// own-row lifecycle as the wire test (created through the scoped controller, removed in the
|
|
@@ -88,6 +88,7 @@ export function myFlow(manifest, entity, opts = {}) {
|
|
|
88
88
|
}
|
|
89
89
|
// the owner FK is server-forced on the personal surface; sending a value must be pointless
|
|
90
90
|
delete payload[entity.personal.owner];
|
|
91
|
+
await freshUniques(makeApi(api, manifest), entity, payload);
|
|
91
92
|
return { payload, samples };
|
|
92
93
|
}
|
|
93
94
|
|
package/test/flows/rest.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { makeApi } from '../api.js';
|
|
2
2
|
import { expect, test } from '../fixtures.js';
|
|
3
3
|
import { resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// The same contract over the generated REST controllers, no browser: isolates backend
|
|
7
7
|
// failures from UI failures and verifies the manifest's field names bind.
|
|
@@ -19,6 +19,7 @@ export function restFlow(manifest, entity, opts = {}) {
|
|
|
19
19
|
for (const sample of await resolveRelationSamples(api, manifest, entity)) {
|
|
20
20
|
payload[sample.relation.name] = sample.id;
|
|
21
21
|
}
|
|
22
|
+
await freshUniques(client, entity, payload);
|
|
22
23
|
|
|
23
24
|
const created = await client.create(entity, payload);
|
|
24
25
|
const id = created?.[idProperty];
|
package/test/form.js
CHANGED
|
@@ -16,21 +16,31 @@ export async function fillField(page, field, value, opts = {}) {
|
|
|
16
16
|
await input.fill(String(value));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// The x-h-select directive hides its input and
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// The x-h-select directive hides its input and appends a button[role=combobox] trigger beside it;
|
|
20
|
+
// options carry role=option.
|
|
21
|
+
// The generated manage/document forms give that input id="f_<Name>", so the trigger is reached
|
|
22
|
+
// through its sibling - independent of how the field label reads. The accessible name is the
|
|
23
|
+
// fallback for a form whose select input carries no id (the my/partner surfaces).
|
|
24
|
+
async function relationTrigger(page, relation) {
|
|
25
|
+
const input = page.locator('#f_' + relation.name);
|
|
26
|
+
if (await input.count()) return input.locator('xpath=../button[@role="combobox"]');
|
|
22
27
|
// Anchored prefix match: the combobox accessible name is the label plus the placeholder or
|
|
23
28
|
// selected value ("Country Select a Country..."), so exact matching finds nothing - while a
|
|
24
29
|
// bare substring match collides with longer sibling labels ("Type" also hits "Chart Type").
|
|
25
30
|
const label = relation.label ?? relation.name;
|
|
26
31
|
const anchored = new RegExp('^' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
|
27
|
-
|
|
32
|
+
return page.getByRole('combobox', { name: anchored }).first();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function pickDropdown(page, relation, optionText) {
|
|
36
|
+
const trigger = await relationTrigger(page, relation);
|
|
37
|
+
await trigger.click();
|
|
28
38
|
const option = page.getByRole('option', { name: optionText }).first();
|
|
29
39
|
try {
|
|
30
40
|
await option.click({ timeout: 10_000 });
|
|
31
41
|
} catch {
|
|
32
42
|
// the option list re-rendered mid-click (async option load reflow) - reopen and retry
|
|
33
|
-
await
|
|
43
|
+
await trigger.click();
|
|
34
44
|
await option.click({ force: true });
|
|
35
45
|
}
|
|
36
46
|
}
|
package/test/sample-values.js
CHANGED
|
@@ -11,6 +11,7 @@ function rand(chars, n) {
|
|
|
11
11
|
// cleanup marker and the searchable handle); short strings always contain a digit so
|
|
12
12
|
// they can never collide with letter-only nomenclature seeds (ISO codes etc.).
|
|
13
13
|
export function sampleValue(field) {
|
|
14
|
+
if (field.unique && field.type !== 'string') return uniqueValue(field);
|
|
14
15
|
switch (field.type) {
|
|
15
16
|
case 'string': {
|
|
16
17
|
const len = field.length ?? 64;
|
|
@@ -40,8 +41,10 @@ export function sampleValue(field) {
|
|
|
40
41
|
// A `pattern:` field (an authored regex, or the address regex behind `format: email`) is rejected
|
|
41
42
|
// by the generated controller with 400 unless the value has the declared shape, so the marker value
|
|
42
43
|
// above has to be traded for one that matches: the candidates are tried in order and the first that
|
|
43
|
-
// both matches and fits the declared length wins.
|
|
44
|
-
//
|
|
44
|
+
// both matches and fits the declared length wins. When nothing here matches (an IBAN-shaped regex
|
|
45
|
+
// mixing character classes by position), an optional field is left unset - the record is valid
|
|
46
|
+
// without it, and no flow needs it filled (dirigible #7525) - while a required one keeps the plain
|
|
47
|
+
// marker: the controller's own 400 then names the field and the pattern, which is the message the
|
|
45
48
|
// module author needs, and a silent near-miss would not.
|
|
46
49
|
function shaped(field, value) {
|
|
47
50
|
let regex;
|
|
@@ -59,7 +62,60 @@ function shaped(field, value) {
|
|
|
59
62
|
token, // letters and digits, no separator
|
|
60
63
|
rand(DIGITS, 10), // a numeric code
|
|
61
64
|
];
|
|
62
|
-
|
|
65
|
+
const match = candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate));
|
|
66
|
+
return match ?? (field.required ? value : undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A `unique:` field refuses a second row carrying its value with 409 - the module's guard doing its
|
|
70
|
+
// job. The per-type constants above are the one value certain to collide: with a seeded row, with
|
|
71
|
+
// the row a parallel flow of the same run is creating, or with one an earlier failed run left
|
|
72
|
+
// behind (dirigible #7530). So a unique field draws a fresh value per record, away from the range
|
|
73
|
+
// an author seeds (a year, a small code): an integer in the hundred-thousands, a date in the 22nd
|
|
74
|
+
// century. Strings need none of this - the marker above is already random.
|
|
75
|
+
function uniqueValue(field) {
|
|
76
|
+
const n = 100000 + Math.floor(Math.random() * 900000);
|
|
77
|
+
switch (field.type) {
|
|
78
|
+
case 'integer':
|
|
79
|
+
case 'bigint':
|
|
80
|
+
return n;
|
|
81
|
+
case 'decimal':
|
|
82
|
+
case 'double':
|
|
83
|
+
return n + 0.25;
|
|
84
|
+
case 'date':
|
|
85
|
+
case 'timestamp':
|
|
86
|
+
case 'datetime': {
|
|
87
|
+
const at = new Date(Date.UTC(2100, 0, 1) + (n % 36500) * 86400000);
|
|
88
|
+
return field.type === 'date' ? at.toISOString().slice(0, 10) : at.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return sampleValue({ ...field, unique: false });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A drawn value is unlikely to collide, not certain not to - probe the live rows for each unique
|
|
96
|
+
// field of the record through the controller's filtered read and redraw on a hit, so the create the
|
|
97
|
+
// flow is about to send cannot be refused for a value that is already taken. The value goes as a
|
|
98
|
+
// string - the controller coerces it to the property's own type (an Integer would not bind to a Long
|
|
99
|
+
// or BigDecimal column). A probe that fails (an older controller without /search) keeps the value.
|
|
100
|
+
export async function freshUniques(client, entity, record) {
|
|
101
|
+
let redrawn = false;
|
|
102
|
+
for (const field of editableFields(entity)) {
|
|
103
|
+
if (!field.unique || !(field.name in record)) continue;
|
|
104
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
105
|
+
let rows;
|
|
106
|
+
try {
|
|
107
|
+
rows = await client.search(entity, [{ propertyName: field.name, operator: 'EQ', value: String(record[field.name]) }]);
|
|
108
|
+
} catch {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (!rows?.length) break;
|
|
112
|
+
record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
|
|
113
|
+
redrawn = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// a redrawn field may be the right-hand side of a compare check - re-derive its left operand
|
|
117
|
+
if (redrawn) steerCompares(entity, record);
|
|
118
|
+
return record;
|
|
63
119
|
}
|
|
64
120
|
|
|
65
121
|
export function editableFields(entity) {
|
|
@@ -70,17 +126,25 @@ export function editableFields(entity) {
|
|
|
70
126
|
// against live target rows).
|
|
71
127
|
export function sampleRecord(entity) {
|
|
72
128
|
const record = {};
|
|
73
|
-
for (const field of editableFields(entity))
|
|
129
|
+
for (const field of editableFields(entity)) {
|
|
130
|
+
const value = sampleValue(field);
|
|
131
|
+
if (value !== undefined) record[field.name] = value;
|
|
132
|
+
}
|
|
74
133
|
// an exactlyOne check rejects a record where more than one of the named fields is set -
|
|
75
134
|
// keep only the first of each declared set
|
|
76
135
|
for (const set of entity.exactlyOne ?? []) {
|
|
77
136
|
for (const name of set.slice(1)) delete record[name];
|
|
78
137
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
steerCompares(entity, record);
|
|
139
|
+
return record;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A compare check relates the record's own field to a second value - another of its fields, or a
|
|
143
|
+
// literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
|
|
144
|
+
// strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
|
|
145
|
+
// `le 5`. Derive the left operand from whichever right-hand side the check names, by the smallest
|
|
146
|
+
// step that satisfies the declared operator (equality satisfies ge/le/eq).
|
|
147
|
+
function steerCompares(entity, record) {
|
|
84
148
|
for (const check of entity.compare ?? []) {
|
|
85
149
|
if (!(check.field in record)) continue;
|
|
86
150
|
const type = (entity.fields ?? []).find((f) => f.name === check.field)?.type;
|