@aerokit/sdk 14.69.0 → 14.71.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/flows/crud.js +20 -3
- package/test/flows/list.js +8 -1
- package/test/flows/multilingual.js +15 -7
- package/test/flows/my.js +5 -2
- package/test/flows/rest.js +5 -2
- package/test/flows/shell.js +1 -0
- package/test/form.js +67 -17
- package/test/sample-values.js +74 -0
package/package.json
CHANGED
package/test/flows/crud.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
2
|
import { makeApi } from '../api.js';
|
|
3
3
|
import { fillField, fillForm, resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniqueKeys, freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
|
|
7
7
|
// layout (manage-list, master-detail, document) and searching the string columns server-side.
|
|
@@ -36,6 +36,8 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
36
36
|
const cfg = opts.extend?.entities?.[entity.name] ?? {};
|
|
37
37
|
const skip = new Set(cfg.skip ?? []);
|
|
38
38
|
if (skip.has('crud')) return;
|
|
39
|
+
// no power page to walk: a composition child is reached through its parent (dirigible #7545)
|
|
40
|
+
if (!entity.route) return;
|
|
39
41
|
// A hierarchy entity lists as a tree, and a calendar/slots entity replaces the table page
|
|
40
42
|
// entirely - no filter row / data rows to drive the walk below; create/read/update/delete
|
|
41
43
|
// stays covered by the REST flow. Same for an entity without a string handle field (nothing
|
|
@@ -47,6 +49,8 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
47
49
|
const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
|
|
48
50
|
const handle = handleField(entity);
|
|
49
51
|
const relationSamples = await resolveRelationSamples(api, manifest, entity);
|
|
52
|
+
const taken = await freshUniqueKeys(makeApi(api, manifest), entity, record, relationSamples);
|
|
53
|
+
test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
|
|
50
54
|
|
|
51
55
|
const created = record[handle.name];
|
|
52
56
|
const listRoute = entity.route.replace(/[#/]/g, '\\$&');
|
|
@@ -61,7 +65,7 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
61
65
|
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
62
66
|
// A create lands on the NEW record's page when there is more to do there - a document's
|
|
63
67
|
// line items, a manage record's detail collections - and on the list otherwise. Saving an
|
|
64
|
-
// edit
|
|
68
|
+
// edit returns to the list, except on a document, whose Save stays on it (#7359).
|
|
65
69
|
const recordPage = entity.layout === 'document' ? '/[^/]+/edit' : '(/[^/]+/edit)?';
|
|
66
70
|
await expect(page).toHaveURL(new RegExp(listRoute + recordPage + '$'));
|
|
67
71
|
if (page.url().endsWith('/edit')) await page.goto(manifest.standaloneShell + entity.route);
|
|
@@ -78,7 +82,20 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
78
82
|
// gets overwritten by the load and Save persists the OLD value
|
|
79
83
|
await expect(page.locator('#f_' + handle.name)).toHaveValue(created);
|
|
80
84
|
await fillField(page, handle, updated, opts);
|
|
81
|
-
|
|
85
|
+
if (entity.layout === 'document') {
|
|
86
|
+
// A document Save STAYS on the document and re-reads it (#7359): wait for the update to
|
|
87
|
+
// land, then leave for the list the way a user does (dirigible #7545).
|
|
88
|
+
const save = page.getByRole('button', { name: 'Save', exact: true });
|
|
89
|
+
const saved = page.waitForResponse((response) => response.request().method() === 'PUT' && response.ok());
|
|
90
|
+
await save.click();
|
|
91
|
+
await saved;
|
|
92
|
+
// Save is disabled again once the re-read header is the new pristine state - leaving before
|
|
93
|
+
// that would meet the unsaved-changes guard
|
|
94
|
+
await expect(save).toBeDisabled();
|
|
95
|
+
await page.getByRole('button', { name: 'Back to list', exact: true }).click();
|
|
96
|
+
} else {
|
|
97
|
+
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
98
|
+
}
|
|
82
99
|
await expect(page).toHaveURL(new RegExp(listRoute + '$'));
|
|
83
100
|
await filterBy(page, updated);
|
|
84
101
|
await expect(dataRow(page, updated)).toHaveCount(1);
|
package/test/flows/list.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
2
|
import { labelOf } from '../sample-values.js';
|
|
3
3
|
|
|
4
|
+
// The calendar / slot-picker view root. The slots page renders `x-h-slot-picker.responsive`, and an
|
|
5
|
+
// attribute selector matches an attribute NAME exactly, so the modifier form is listed with its dot
|
|
6
|
+
// escaped (dirigible #7545).
|
|
7
|
+
export const CALENDAR_OR_SLOTS = '[x-h-calendar], [x-h-slot-picker], [x-h-slot-picker\\.responsive]';
|
|
8
|
+
|
|
4
9
|
// The shell breadcrumb's current crumb - the one page element every list layout shares that names
|
|
5
10
|
// the entity (the sidebar label, which the manifest's labelPlural mirrors).
|
|
6
11
|
export function listCrumb(page, entity) {
|
|
@@ -13,6 +18,8 @@ export function listCrumb(page, entity) {
|
|
|
13
18
|
// column header per major field, or (when the entity has no rows yet) the empty state.
|
|
14
19
|
// When seed data is expected, rows/nodes must actually be there.
|
|
15
20
|
export function listFlow(manifest, entity) {
|
|
21
|
+
// no power page: a composition child is reached through its parent (dirigible #7545)
|
|
22
|
+
if (!entity.route) return;
|
|
16
23
|
test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
|
|
17
24
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
18
25
|
await expect(listCrumb(page, entity)).toBeVisible();
|
|
@@ -24,7 +31,7 @@ export function listFlow(manifest, entity) {
|
|
|
24
31
|
}
|
|
25
32
|
if (entity.layout === 'calendar' || entity.layout === 'slots') {
|
|
26
33
|
// the view family renders a calendar / slot picker instead of the table
|
|
27
|
-
await expect(page.locator(
|
|
34
|
+
await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
|
|
28
35
|
return;
|
|
29
36
|
}
|
|
30
37
|
// filter({ visible: true }): the empty-state markup stays in the DOM (x-show) above the
|
|
@@ -11,7 +11,7 @@ const OVERLAY_TIMEOUT = 15_000;
|
|
|
11
11
|
// Needs a concrete sample in the manifest: { language, base, translated }.
|
|
12
12
|
export function multilingualFlow(manifest, entity) {
|
|
13
13
|
const sample = entity.multilingualSample;
|
|
14
|
-
if (!entity.multilingual || !sample) return;
|
|
14
|
+
if (!entity.multilingual || !sample || !entity.route) return;
|
|
15
15
|
|
|
16
16
|
test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
|
|
17
17
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
@@ -26,20 +26,23 @@ export function multilingualFlow(manifest, entity) {
|
|
|
26
26
|
// Set the language key and reload, waiting for the list's own data GET (the paged getAll on the
|
|
27
27
|
// entity's controller) so the assertion starts once the rows are on their way, and answering what
|
|
28
28
|
// that read asked for and got back - which is what tells a slow render from a missing translation.
|
|
29
|
+
// The wait is registered only once the reload has committed: registered before it, it also matched
|
|
30
|
+
// a list GET still in flight from the document the reload replaced, whose body is gone (#7584). The
|
|
31
|
+
// new document issues its own GET only after its scripts have loaded, so it cannot slip past.
|
|
29
32
|
async function reloadIn(page, manifest, entity, language) {
|
|
30
33
|
await page.evaluate(([key, lang]) => localStorage.setItem(key, lang), [LANGUAGE_KEY, language]);
|
|
31
34
|
const controllerPath = manifest.restBase + entity.api;
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
await page.reload({ waitUntil: 'commit' });
|
|
36
|
+
const response = await page.waitForResponse(
|
|
37
|
+
(candidate) => candidate.request().method() === 'GET' && new URL(candidate.url()).pathname === controllerPath,
|
|
34
38
|
{ timeout: OVERLAY_TIMEOUT },
|
|
35
39
|
);
|
|
36
|
-
await page.reload();
|
|
37
|
-
const response = await listRead;
|
|
38
40
|
return {
|
|
39
41
|
language,
|
|
40
42
|
acceptLanguage: await response.request().headerValue('accept-language'),
|
|
41
43
|
status: response.status(),
|
|
42
|
-
body
|
|
44
|
+
// Only the diagnostic reads the body, so a body the browser no longer holds must not fail the flow.
|
|
45
|
+
body: await response.text().catch(() => null),
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -52,9 +55,14 @@ async function expectRowShowing(page, text, read) {
|
|
|
52
55
|
throw new Error(
|
|
53
56
|
`no list row shows '${text}' after reloading in '${read.language}': the list GET sent Accept-Language ` +
|
|
54
57
|
`${read.acceptLanguage ?? '(none)'} and answered ${read.status}, ` +
|
|
55
|
-
`${read.body
|
|
58
|
+
`${describeBody(read.body, text)}; the table's first row reads ` +
|
|
56
59
|
`${firstRow ? `'${firstRow}'` : '(no rows)'}`,
|
|
57
60
|
{ cause: error },
|
|
58
61
|
);
|
|
59
62
|
}
|
|
60
63
|
}
|
|
64
|
+
|
|
65
|
+
function describeBody(body, text) {
|
|
66
|
+
if (body === null) return 'its body unavailable';
|
|
67
|
+
return `${body.includes(text) ? 'WITH' : 'WITHOUT'} '${text}' in its body`;
|
|
68
|
+
}
|
package/test/flows/my.js
CHANGED
|
@@ -2,6 +2,7 @@ import { makeApi } from '../api.js';
|
|
|
2
2
|
import { expect, test } from '../fixtures.js';
|
|
3
3
|
import { resolveRelationSamples } from '../form.js';
|
|
4
4
|
import { freshUniques, sampleRecord } from '../sample-values.js';
|
|
5
|
+
import { CALENDAR_OR_SLOTS } from './list.js';
|
|
5
6
|
|
|
6
7
|
// Wave 2 (UI parity): the personal PAGE renders what the wire serves. Driven by the same
|
|
7
8
|
// own-row lifecycle as the wire test (created through the scoped controller, removed in the
|
|
@@ -28,7 +29,7 @@ function myUiTest(manifest, entity, mine, buildContext, idProperty) {
|
|
|
28
29
|
const layout = entity.personal.layout ?? 'list';
|
|
29
30
|
if (layout === 'calendar' || layout === 'slots') {
|
|
30
31
|
await page.goto(manifest.standaloneShell + entity.personal.route);
|
|
31
|
-
await expect(page.locator(
|
|
32
|
+
await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
34
35
|
if (layout === 'document-chat' || layout === 'document') {
|
|
@@ -40,7 +41,9 @@ function myUiTest(manifest, entity, mine, buildContext, idProperty) {
|
|
|
40
41
|
await page.getByRole('button', { name: /send/i }).first().click();
|
|
41
42
|
await expect(page.getByText(message).first(), 'the sent message must render as a bubble').toBeVisible();
|
|
42
43
|
} else {
|
|
43
|
-
|
|
44
|
+
// visible only: the document carries a second toolbar title (its items table's), which can
|
|
45
|
+
// be the first in DOM order while hidden (dirigible #7545)
|
|
46
|
+
await expect(page.locator('[x-h-toolbar-title]').filter({ visible: true }).first()).toBeVisible();
|
|
44
47
|
}
|
|
45
48
|
return;
|
|
46
49
|
}
|
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 { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniqueKeys, 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.
|
|
@@ -16,7 +16,10 @@ export function restFlow(manifest, entity, opts = {}) {
|
|
|
16
16
|
const handle = handleField(entity);
|
|
17
17
|
// same resolution the UI flow uses: cross-model targets via apiAbsolute, entityStatus
|
|
18
18
|
// relations left to their init: DB default
|
|
19
|
-
|
|
19
|
+
const samples = await resolveRelationSamples(api, manifest, entity);
|
|
20
|
+
const taken = await freshUniqueKeys(client, entity, payload, samples);
|
|
21
|
+
test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
|
|
22
|
+
for (const sample of samples) {
|
|
20
23
|
payload[sample.relation.name] = sample.id;
|
|
21
24
|
}
|
|
22
25
|
await freshUniques(client, entity, payload);
|
package/test/flows/shell.js
CHANGED
|
@@ -4,6 +4,7 @@ import { expect, test } from '../fixtures.js';
|
|
|
4
4
|
// under its nav group and opens the module SPA in the embedded iframe. Requires the
|
|
5
5
|
// navigation module that defines the groups, so it is opt-in: APPTEST_SHELL=1.
|
|
6
6
|
export function shellFlow(manifest, entity) {
|
|
7
|
+
if (!entity.route) return; // no power page, no menu item
|
|
7
8
|
test(`${entity.name}: shared shell menu item opens the module`, async ({ page }) => {
|
|
8
9
|
test.skip(!process.env.APPTEST_SHELL, 'APPTEST_SHELL=1 enables the shared shell flow');
|
|
9
10
|
await page.goto('/services/web/application/');
|
package/test/form.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeApi } from './api.js';
|
|
2
|
+
import { expect } from './fixtures.js';
|
|
2
3
|
import { editableFields, handleField } from './sample-values.js';
|
|
3
4
|
|
|
4
5
|
// Generated form controls: every field input has id="f_<Name>"; a to-one relation is a
|
|
@@ -34,15 +35,25 @@ async function relationTrigger(page, relation) {
|
|
|
34
35
|
|
|
35
36
|
export async function pickDropdown(page, relation, optionText) {
|
|
36
37
|
const trigger = await relationTrigger(page, relation);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
// the option list re-rendered mid-click (async option load reflow) - reopen and retry
|
|
38
|
+
const input = page.locator('#f_' + relation.name);
|
|
39
|
+
// visible only: an option list that is closing, or another select's, must not be the one clicked
|
|
40
|
+
const option = page.getByRole('option', { name: optionText }).filter({ visible: true }).first();
|
|
41
|
+
for (let attempt = 0; ; attempt++) {
|
|
43
42
|
await trigger.click();
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
await option.click({ timeout: 10_000 });
|
|
45
|
+
} catch {
|
|
46
|
+
// the option list re-rendered mid-click (async option load reflow) - reopen and retry
|
|
47
|
+
await trigger.click();
|
|
48
|
+
await option.click({ force: true });
|
|
49
|
+
}
|
|
50
|
+
// The list must be closed before the next relation's trigger is clicked: a click landing while
|
|
51
|
+
// it is still open only dismisses it, and that select stays empty - the second of two relations
|
|
52
|
+
// to the same entity was left at its placeholder that way (dirigible #7545).
|
|
53
|
+
await expect(option).toBeHidden();
|
|
54
|
+
if (!(await input.count()) || (await input.inputValue()) !== '' || attempt > 0) break;
|
|
45
55
|
}
|
|
56
|
+
if (await input.count()) await expect(input, `${relation.name} must hold the picked "${optionText}"`).not.toHaveValue('');
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
// Resolve a live option for each to-one relation: take the first suitable row of the
|
|
@@ -54,7 +65,9 @@ export async function pickDropdown(page, relation, optionText) {
|
|
|
54
65
|
// - a where: option filter narrows the candidate rows to matching ones;
|
|
55
66
|
// - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
|
|
56
67
|
// its filterBy FK becomes the trigger sibling's sample (independent first rows would
|
|
57
|
-
// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options)
|
|
68
|
+
// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options);
|
|
69
|
+
// - a relation an agree check or a composite unique key names keeps a page of candidate rows, so
|
|
70
|
+
// agreeing rows can be chosen here and a free key combination by freshUniqueKeys.
|
|
58
71
|
export async function resolveRelationSamples(request, manifest, entity) {
|
|
59
72
|
const api = makeApi(request, manifest);
|
|
60
73
|
const idProperty = manifest.idProperty ?? 'Id';
|
|
@@ -67,11 +80,13 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
67
80
|
return { rows: await api.list(target, limit), labelFrom: relation.labelFrom ?? handleField(target)?.name ?? 'Name' };
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
const constrained = new Set([...(entity.agree ?? []).flatMap((check) => check.relations), ...(entity.uniqueKeys ?? []).flat()]);
|
|
70
84
|
const samples = [];
|
|
71
85
|
for (const relation of entity.relations ?? []) {
|
|
72
86
|
if (relation.entityStatus) continue;
|
|
73
|
-
// a filtered picker needs a matching candidate, so fetch a page and filter client-side
|
|
74
|
-
|
|
87
|
+
// a filtered picker needs a matching candidate, so fetch a page and filter client-side; a
|
|
88
|
+
// constrained relation needs alternatives to choose from
|
|
89
|
+
const wide = relation.where || relation.leafOnly || constrained.has(relation.name);
|
|
75
90
|
const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
|
|
76
91
|
let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
|
|
77
92
|
if (relation.leafOnly && rows?.length) {
|
|
@@ -91,12 +106,18 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
91
106
|
// relation unset rather than clicking blind
|
|
92
107
|
continue;
|
|
93
108
|
}
|
|
94
|
-
|
|
109
|
+
const sample = {
|
|
95
110
|
relation,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
rows,
|
|
112
|
+
// re-point the sample at another of its candidate rows
|
|
113
|
+
use(row) {
|
|
114
|
+
this.row = row;
|
|
115
|
+
this.id = row[idProperty];
|
|
116
|
+
this.label = row[labelFrom] ?? String(row[idProperty]);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
sample.use(rows[0]);
|
|
120
|
+
samples.push(sample);
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
// cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
|
|
@@ -104,6 +125,9 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
104
125
|
const dependsOn = sample.relation.dependsOn;
|
|
105
126
|
if (!dependsOn?.filterBy) continue;
|
|
106
127
|
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
128
|
+
// a cascade pair is chosen together - neither side may be swapped for a candidate later
|
|
129
|
+
sample.rows = [sample.row];
|
|
130
|
+
if (trigger) trigger.rows = [trigger.row];
|
|
107
131
|
const impliedId = sample.row[dependsOn.filterBy];
|
|
108
132
|
if (!trigger || impliedId == null || trigger.id === impliedId) continue;
|
|
109
133
|
const path = trigger.relation.apiAbsolute ?? (trigger.relation.api ? manifest.restBase + trigger.relation.api : null);
|
|
@@ -111,20 +135,46 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
111
135
|
const row = await api.getPath(`${path}/${impliedId}`);
|
|
112
136
|
if (!row) continue;
|
|
113
137
|
trigger.row = row;
|
|
138
|
+
trigger.rows = [row];
|
|
114
139
|
trigger.id = impliedId;
|
|
115
140
|
trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
|
|
116
141
|
}
|
|
142
|
+
alignAgreeing(entity, samples);
|
|
117
143
|
return samples;
|
|
118
144
|
}
|
|
119
145
|
|
|
146
|
+
// An agree check refuses a record whose two relations point at targets that differ on onProperty
|
|
147
|
+
// (a transfer between stores of two companies), and the first row of each target is an arbitrary
|
|
148
|
+
// pair. Choose the first lead row for which every other side has a candidate carrying the same value.
|
|
149
|
+
function alignAgreeing(entity, samples) {
|
|
150
|
+
for (const check of entity.agree ?? []) {
|
|
151
|
+
const sides = check.relations.map((name) => samples.find((s) => s.relation.name === name));
|
|
152
|
+
if (sides.some((side) => !side)) continue; // an unset side has nothing to disagree about
|
|
153
|
+
const [lead, ...others] = sides;
|
|
154
|
+
for (const row of lead.rows) {
|
|
155
|
+
const value = row[check.onProperty];
|
|
156
|
+
if (value == null) continue;
|
|
157
|
+
const matches = others.map((side) => side.rows.find((candidate) => String(candidate[check.onProperty]) === String(value)));
|
|
158
|
+
if (matches.some((match) => !match)) continue;
|
|
159
|
+
lead.use(row);
|
|
160
|
+
others.forEach((side, i) => side.use(matches[i]));
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) {
|
|
121
167
|
for (const field of editableFields(entity)) {
|
|
122
168
|
if (record[field.name] === undefined) continue;
|
|
123
169
|
await fillField(page, field, record[field.name], opts);
|
|
124
170
|
}
|
|
171
|
+
// a readOnly relation renders as a disabled combobox (an update-time calculated action, a
|
|
172
|
+
// platform-owned FK) - the server sets it, so there is nothing to pick (dirigible #7554); the
|
|
173
|
+
// REST flows still post its sample, which the calculated action overwrites or checks
|
|
174
|
+
const pickable = relationSamples.filter((s) => !s.relation.readOnly);
|
|
125
175
|
// cascade order: a dependsOn trigger must be picked BEFORE its dependent, so the narrowed
|
|
126
176
|
// option list is the one the dependent's sample was chosen from
|
|
127
|
-
const triggers =
|
|
128
|
-
const dependents =
|
|
177
|
+
const triggers = pickable.filter((s) => !s.relation.dependsOn);
|
|
178
|
+
const dependents = pickable.filter((s) => s.relation.dependsOn);
|
|
129
179
|
for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
|
|
130
180
|
}
|
package/test/sample-values.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2
|
+
const LOWER = ALPHA.toLowerCase();
|
|
2
3
|
const DIGITS = '0123456789';
|
|
3
4
|
|
|
4
5
|
function rand(chars, n) {
|
|
@@ -33,6 +34,12 @@ export function sampleValue(field) {
|
|
|
33
34
|
// full ISO instant: the generated Java entities bind java.time.Instant, which rejects a
|
|
34
35
|
// zone-less value; the UI fill slices this to the datetime-local shape
|
|
35
36
|
return '2026-07-08T10:00:00Z';
|
|
37
|
+
// the input shapes of an <input type="month|week"> - a month column is VARCHAR(7), so the
|
|
38
|
+
// default marker overflows it (dirigible #7545)
|
|
39
|
+
case 'month':
|
|
40
|
+
return '2026-07';
|
|
41
|
+
case 'week':
|
|
42
|
+
return '2026-W28';
|
|
36
43
|
default:
|
|
37
44
|
return 'APPTEST-' + rand(ALPHA + DIGITS, 6);
|
|
38
45
|
}
|
|
@@ -61,6 +68,10 @@ function shaped(field, value) {
|
|
|
61
68
|
`apptest.${token}@apptest.example.com`, // format: email and other address-shaped patterns
|
|
62
69
|
token, // letters and digits, no separator
|
|
63
70
|
rand(DIGITS, 10), // a numeric code
|
|
71
|
+
// a letters-only code (an ISO language or country code, ^[a-z]{2,3}$) - kept short, the shape
|
|
72
|
+
// such a pattern declares, and within the declared length (dirigible #7545)
|
|
73
|
+
rand(LOWER, Math.min(field.length ?? 3, 3)),
|
|
74
|
+
rand(ALPHA, Math.min(field.length ?? 3, 3)),
|
|
64
75
|
];
|
|
65
76
|
const match = candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate));
|
|
66
77
|
return match ?? (field.required ? value : undefined);
|
|
@@ -87,6 +98,10 @@ function uniqueValue(field) {
|
|
|
87
98
|
const at = new Date(Date.UTC(2100, 0, 1) + (n % 36500) * 86400000);
|
|
88
99
|
return field.type === 'date' ? at.toISOString().slice(0, 10) : at.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
89
100
|
}
|
|
101
|
+
case 'month':
|
|
102
|
+
return `${2100 + (n % 900)}-${String(1 + (n % 12)).padStart(2, '0')}`;
|
|
103
|
+
case 'week':
|
|
104
|
+
return `${2100 + (n % 900)}-W${String(1 + (n % 52)).padStart(2, '0')}`;
|
|
90
105
|
default:
|
|
91
106
|
return sampleValue({ ...field, unique: false });
|
|
92
107
|
}
|
|
@@ -118,6 +133,65 @@ export async function freshUniques(client, entity, record) {
|
|
|
118
133
|
return record;
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
// A composite unique key (`unique: [ProjectTimesheet, Employee]` - one timesheet per employee per
|
|
137
|
+
// project-month) refuses a second row carrying the same combination with 409, and the first row of
|
|
138
|
+
// each relation is the combination a seed most likely holds already (dirigible #7545). Walk the
|
|
139
|
+
// candidate rows of the key's relations (and redraw its own fields) until the controller's filtered
|
|
140
|
+
// read finds no row with that combination, keeping every agree check satisfied. Answers the key no
|
|
141
|
+
// free combination was found for - the flow skips with it - or null when the record is free. Call it
|
|
142
|
+
// before the relation samples are copied into the record.
|
|
143
|
+
export async function freshUniqueKeys(client, entity, record, samples) {
|
|
144
|
+
for (const key of entity.uniqueKeys ?? []) {
|
|
145
|
+
const related = key.map((name) => samples.find((s) => s.relation.name === name)).filter(Boolean);
|
|
146
|
+
const drawn = editableFields(entity).filter((f) => key.includes(f.name) && f.name in record);
|
|
147
|
+
// a member left unset is NULL, and a NULL never collides
|
|
148
|
+
if (related.length + drawn.length < key.length) continue;
|
|
149
|
+
let free = false;
|
|
150
|
+
for (const combination of combinations(related.map((s) => s.rows), related.length ? 50 : 5)) {
|
|
151
|
+
combination.forEach((row, i) => related[i].use(row));
|
|
152
|
+
if (!agreeing(entity, samples)) continue;
|
|
153
|
+
const value = (name) => related.find((s) => s.relation.name === name)?.id ?? record[name];
|
|
154
|
+
let rows;
|
|
155
|
+
try {
|
|
156
|
+
rows = await client.search(entity, key.map((name) => ({ propertyName: name, operator: 'EQ', value: String(value(name)) })));
|
|
157
|
+
} catch {
|
|
158
|
+
free = true; // an older controller without /search - keep the combination, as freshUniques does
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (!rows?.length) {
|
|
162
|
+
free = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
for (const field of drawn) record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
|
|
166
|
+
}
|
|
167
|
+
if (!free) return key;
|
|
168
|
+
if (drawn.length) steerCompares(entity, record);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The first `limit` entries of the cartesian product of the candidate lists, in order - so the first
|
|
174
|
+
// combination is the one the samples started with. No lists yields `limit` empty combinations: a key
|
|
175
|
+
// of own fields only is retried by redrawing them.
|
|
176
|
+
function* combinations(lists, limit) {
|
|
177
|
+
const index = lists.map(() => 0);
|
|
178
|
+
for (let produced = 0; produced < limit; produced++) {
|
|
179
|
+
yield lists.map((list, i) => list[index[i]]);
|
|
180
|
+
let i = lists.length - 1;
|
|
181
|
+
while (i >= 0 && ++index[i] === lists[i].length) index[i--] = 0;
|
|
182
|
+
if (lists.length && i < 0) return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Whether the relation samples satisfy every agree check of the entity (an unset side has nothing to
|
|
187
|
+
// disagree about).
|
|
188
|
+
export function agreeing(entity, samples) {
|
|
189
|
+
return (entity.agree ?? []).every((check) => {
|
|
190
|
+
const values = check.relations.map((name) => samples.find((s) => s.relation.name === name)?.row?.[check.onProperty]);
|
|
191
|
+
return values.some((value) => value == null) || values.every((value) => String(value) === String(values[0]));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
121
195
|
export function editableFields(entity) {
|
|
122
196
|
return (entity.fields ?? []).filter((f) => !f.readOnly && !f.primaryKey && !f.generated);
|
|
123
197
|
}
|