@aerokit/sdk 15.13.0 → 15.15.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 +1 -0
- package/test/fixtures.js +3 -1
- package/test/form.js +41 -6
- package/test/sample-values.js +15 -0
package/package.json
CHANGED
package/test/api.js
CHANGED
|
@@ -16,6 +16,7 @@ export function makeApi(request, manifest) {
|
|
|
16
16
|
// absolute controller path (a cross-model relation target owned by another module)
|
|
17
17
|
listPath: (path, limit = 20) => request.get(`${path}?$limit=${limit}`).then(asJson),
|
|
18
18
|
getPath: (path) => request.get(path).then(asJson),
|
|
19
|
+
searchPath: (path, conditions) => request.post(`${path}/search`, { data: { conditions } }).then(asJson),
|
|
19
20
|
count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)),
|
|
20
21
|
get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson),
|
|
21
22
|
getResponse: (entity, id) => request.get(url(entity, '/' + id)),
|
package/test/fixtures.js
CHANGED
|
@@ -5,7 +5,8 @@ import { login } from './session.js';
|
|
|
5
5
|
// Pre-authenticated Playwright test:
|
|
6
6
|
// - authState: worker-scoped form login, one session per worker
|
|
7
7
|
// - storageState: every page starts logged in
|
|
8
|
-
// - api: an APIRequestContext carrying the same session, for REST-level checks
|
|
8
|
+
// - api: an APIRequestContext carrying the same session, for REST-level checks; its writes name UTC as
|
|
9
|
+
// their zone, the one a sample slot start is computed in (dirigible #8097)
|
|
9
10
|
export const test = base.extend({
|
|
10
11
|
authState: [
|
|
11
12
|
async ({ browser }, use) => {
|
|
@@ -20,6 +21,7 @@ export const test = base.extend({
|
|
|
20
21
|
const request = await playwright.request.newContext({
|
|
21
22
|
baseURL: BASE_URL,
|
|
22
23
|
storageState: authState,
|
|
24
|
+
extraHTTPHeaders: { 'X-Time-Zone': 'UTC' },
|
|
23
25
|
});
|
|
24
26
|
await use(request);
|
|
25
27
|
await request.dispose();
|
package/test/form.js
CHANGED
|
@@ -66,8 +66,11 @@ export async function pickDropdown(page, relation, optionText) {
|
|
|
66
66
|
// - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
|
|
67
67
|
// its filterBy FK becomes the trigger sibling's sample (independent first rows would
|
|
68
68
|
// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options);
|
|
69
|
+
// a valueFrom dependent is the row the form narrows it to once the trigger is picked;
|
|
69
70
|
// - 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
|
|
71
|
+
// agreeing rows can be chosen here and a free key combination by freshUniqueKeys;
|
|
72
|
+
// - a target labelled by a stampOn: issue number (labelDocumentNumber) prefers a stamped row: the
|
|
73
|
+
// picker shows every unstamped one as the same draft marker (#7722, #7823).
|
|
71
74
|
export async function resolveRelationSamples(request, manifest, entity) {
|
|
72
75
|
const api = makeApi(request, manifest);
|
|
73
76
|
const idProperty = manifest.idProperty ?? 'Id';
|
|
@@ -87,7 +90,12 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
87
90
|
// a filtered picker needs a matching candidate, so fetch a page and filter client-side; a
|
|
88
91
|
// constrained relation needs alternatives to choose from
|
|
89
92
|
const wide =
|
|
90
|
-
relation.where ||
|
|
93
|
+
relation.where ||
|
|
94
|
+
relation.leafOnly ||
|
|
95
|
+
relation.pickable ||
|
|
96
|
+
relation.forbiddenTarget ||
|
|
97
|
+
relation.labelDocumentNumber ||
|
|
98
|
+
constrained.has(relation.name);
|
|
91
99
|
const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
|
|
92
100
|
let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
|
|
93
101
|
// A picker rule narrows what the chooser OFFERS (dirigible #7663) and the child's own
|
|
@@ -106,6 +114,12 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
106
114
|
const idProp = manifest.idProperty ?? 'Id';
|
|
107
115
|
rows = rows.filter((row) => !fetched.some((other) => other[prop] === row[idProp]));
|
|
108
116
|
}
|
|
117
|
+
if (rows?.length && relation.labelDocumentNumber) {
|
|
118
|
+
// an optional relation with no stamped row is left unset; a required one picks "(draft)"
|
|
119
|
+
const stamped = rows.filter((row) => !isDraftNumber(row[labelFrom]));
|
|
120
|
+
if (stamped.length) rows = stamped;
|
|
121
|
+
else if (!relation.required) continue;
|
|
122
|
+
}
|
|
109
123
|
if (!rows?.length) {
|
|
110
124
|
// a required FK cannot be satisfied - fail loudly; an optional one is simply left unset
|
|
111
125
|
if (relation.required) throw new Error(`Relation ${entity.name}.${relation.name}: ${noCandidateReason(relation)}`);
|
|
@@ -124,7 +138,8 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
124
138
|
use(row) {
|
|
125
139
|
this.row = row;
|
|
126
140
|
this.id = row[idProperty];
|
|
127
|
-
this.label =
|
|
141
|
+
this.label =
|
|
142
|
+
relation.labelDocumentNumber && isDraftNumber(row[labelFrom]) ? DRAFT_MARKER : (row[labelFrom] ?? String(row[idProperty]));
|
|
128
143
|
},
|
|
129
144
|
};
|
|
130
145
|
sample.use(rows[0]);
|
|
@@ -134,6 +149,20 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
134
149
|
// cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
|
|
135
150
|
for (const sample of samples) {
|
|
136
151
|
const dependsOn = sample.relation.dependsOn;
|
|
152
|
+
if (dependsOn?.valueFrom) {
|
|
153
|
+
// The form copies the trigger row's valueFrom and offers only the rows whose filterBy (default:
|
|
154
|
+
// the key) holds it, so the dependent is that row, not its own first one (#7957).
|
|
155
|
+
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
156
|
+
const value = trigger?.row[dependsOn.valueFrom];
|
|
157
|
+
const path = sample.relation.apiAbsolute ?? (sample.relation.api ? manifest.restBase + sample.relation.api : null);
|
|
158
|
+
if (value == null || !path) continue; // nothing copied: the picker offers every row
|
|
159
|
+
const [row] = (await api.searchPath(path, [{ propertyName: dependsOn.filterBy ?? idProperty, operator: 'EQ', value }])) ?? [];
|
|
160
|
+
if (!row) continue;
|
|
161
|
+
sample.use(row);
|
|
162
|
+
sample.rows = [row];
|
|
163
|
+
trigger.rows = [trigger.row];
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
137
166
|
if (!dependsOn?.filterBy) continue;
|
|
138
167
|
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
139
168
|
// a cascade pair is chosen together - neither side may be swapped for a candidate later
|
|
@@ -145,10 +174,8 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
145
174
|
if (!path) continue;
|
|
146
175
|
const row = await api.getPath(`${path}/${impliedId}`);
|
|
147
176
|
if (!row) continue;
|
|
148
|
-
trigger.row
|
|
177
|
+
trigger.use(row);
|
|
149
178
|
trigger.rows = [row];
|
|
150
|
-
trigger.id = impliedId;
|
|
151
|
-
trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
|
|
152
179
|
}
|
|
153
180
|
alignAgreeing(entity, samples);
|
|
154
181
|
return samples;
|
|
@@ -190,6 +217,14 @@ export async function fillForm(page, manifest, entity, record, relationSamples,
|
|
|
190
217
|
for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
// The option text the picker gives a stampOn: issue number (HarmoniaFormat.documentNumber): a UUID
|
|
221
|
+
// placeholder - a document not issued yet - reads as the draft marker, any other value as itself.
|
|
222
|
+
// The marker is the English default; a walk in another UI language would need the translated one.
|
|
223
|
+
const DRAFT_MARKER = '(draft)';
|
|
224
|
+
function isDraftNumber(value) {
|
|
225
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value));
|
|
226
|
+
}
|
|
227
|
+
|
|
193
228
|
// One term of a picker rule or of a forbidWhen reaching through a relation, as the manifest writes
|
|
194
229
|
// it: `{ by, op, value }` with `op` one of eq / ne / present / absent, a presence test carrying no
|
|
195
230
|
// value. Compared as strings, because an id arrives as a number from REST and as text from a
|
package/test/sample-values.js
CHANGED
|
@@ -210,9 +210,24 @@ export function sampleRecord(entity) {
|
|
|
210
210
|
for (const name of set.slice(1)) delete record[name];
|
|
211
211
|
}
|
|
212
212
|
steerCompares(entity, record);
|
|
213
|
+
if (entity.slots) record[entity.slots.start] = slotSample(entity.slots);
|
|
213
214
|
return record;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// A slots entity's start (dirigible #8097): the server refuses one on a closed day, outside the
|
|
218
|
+
// hours or off the grid, and a second booking of a slot. A random free-looking slot - an open day of
|
|
219
|
+
// a far-future century, a grid start inside the hours - read in UTC, the zone the api fixture names.
|
|
220
|
+
function slotSample({ open, close, step, disabledDays = [] }) {
|
|
221
|
+
const minutes = (hhmm) => hhmm.split(':').reduce((h, m) => Number(h) * 60 + Number(m));
|
|
222
|
+
const from = minutes(open);
|
|
223
|
+
const slots = Math.max(1, Math.ceil((minutes(close) - from) / step));
|
|
224
|
+
let day = new Date(Date.UTC(2100, 0, 1) + Math.floor(Math.random() * 36500) * 86400000);
|
|
225
|
+
while (disabledDays.includes(day.getUTCDay())) day = new Date(day.getTime() + 86400000);
|
|
226
|
+
const at = from + step * Math.floor(Math.random() * slots);
|
|
227
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
228
|
+
return `${day.toISOString().slice(0, 10)}T${pad(Math.floor(at / 60))}:${pad(at % 60)}:00Z`;
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
// A compare check relates the record's own field to a second value - another of its fields, or a
|
|
217
232
|
// literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
|
|
218
233
|
// strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
|