@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerokit/sdk",
3
- "version": "15.13.0",
3
+ "version": "15.15.0",
4
4
  "description": "Unified TypeScript SDK for modern cloud platforms.",
5
5
  "scripts": {
6
6
  "clean": "rm -rf dist",
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 || relation.leafOnly || relation.pickable || relation.forbiddenTarget || constrained.has(relation.name);
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 = row[labelFrom] ?? String(row[idProperty]);
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 = 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
@@ -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