@ditojs/admin 3.0.0 → 3.1.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.
@@ -12,7 +12,9 @@ import { DataSchemaResolver } from './DataSchemaResolver.js'
12
12
  import { isNested, isSourceWithResource } from './schema/structure.js'
13
13
  import { isEmptySchema } from './schema/lookup.js'
14
14
  import {
15
- shouldRenderSchema,
15
+ getSchemaValue,
16
+ shouldSetDefaultValue,
17
+ getDefaultValue,
16
18
  initializeData,
17
19
  computeValue,
18
20
  hasValueFromDataSchema,
@@ -23,9 +25,10 @@ import {
23
25
  // FormModel holds the state of the data edited by a form, view or dialog that
24
26
  // is derived from schema and data, independently of what is rendered:
25
27
  //
26
- // - Missing values are set to their defaults when the data is set up, except
27
- // the values of components with `compute()`, which fall back to their
28
- // defaults when `compute()` doesn't return a value, unlike in new data, see
28
+ // - Missing values are set to their defaults when the data is set up, and when
29
+ // they go missing later, e.g. in items that code adds, except the values of
30
+ // components with `compute()`, which fall back to their defaults when
31
+ // `compute()` doesn't return a value, unlike in new data, see
29
32
  // `initializeData()`.
30
33
  // - The items of lists with `orderKey` are numbered when the data is set up,
31
34
  // which doesn't make the data dirty.
@@ -94,12 +97,19 @@ export class FormModel {
94
97
  // The entries are read through a computed property, so that the watchers
95
98
  // of the entries can check synchronously whether their entry is still
96
99
  // current, see `createComputedValueScope()`.
97
- this.computedValueEntries = computed(() => this.getComputedValueEntries())
100
+ this.dataEntries = computed(() => this.getDataEntries())
98
101
  watch(
99
- () => this.computedValueEntries.value,
102
+ () => this.dataEntries.value.computedValueEntries,
100
103
  entries => this.updateComputedValueRecords(entries),
101
104
  { immediate: true }
102
105
  )
106
+ // Values that go missing after the data was set up, e.g. in items that
107
+ // code adds, get their defaults too:
108
+ watch(
109
+ () => this.dataEntries.value.entriesWithMissingValues,
110
+ entries => this.setDefaultValues(entries),
111
+ { immediate: true }
112
+ )
103
113
  if (getSourceSchema) {
104
114
  // Data that is set up, e.g. loaded, saved or applied, isn't dirty:
105
115
  watch(
@@ -293,32 +303,79 @@ export class FormModel {
293
303
  }
294
304
  }
295
305
 
296
- // Returns the entries of `processSchemaData()` for all components with
297
- // computed values whose `if` doesn't evaluate to `false`, by component path.
298
- // Called by a computed property, so that it runs again when the data
299
- // structure changes, e.g. when list items are added or removed, or when `if`
300
- // conditions change.
301
- getComputedValueEntries() {
306
+ // Walks the data and returns the entries of `processSchemaData()` of the
307
+ // components whose `if` doesn't evaluate to `false`: the ones with computed
308
+ // values, by component path, and the ones whose values are missing. Called
309
+ // by a computed property, so that it runs again when the data structure
310
+ // changes, e.g. when list items are added or removed, values go missing, or
311
+ // `if` conditions change.
312
+ getDataEntries() {
302
313
  const schema = this.getSchema()
303
314
  const data = this.getData()
304
- const entries = new Map()
315
+ const computedValueEntries = new Map()
316
+ const entriesWithMissingValues = []
305
317
  if (data && !isEmptySchema(schema)) {
306
318
  processSchemaData(schema, data, {
307
319
  dataPath: this.dataPath,
308
320
  componentPath: this.componentPath,
309
321
  shouldProcess: entry => (
310
- !isSourceWithResource(entry.schema) &&
311
- shouldRenderSchema(entry.schema, this.createEntryContext(entry))
322
+ !isSourceWithResource(entry.schema) && this.isEntryShown(entry)
312
323
  ),
313
324
  before: entry => {
314
325
  if (hasComputedValueSource(entry.schema)) {
315
- entries.set(entry.componentPath, entry)
326
+ computedValueEntries.set(entry.componentPath, entry)
327
+ }
328
+ if (this.shouldSetDefaultValue(entry)) {
329
+ entriesWithMissingValues.push(entry)
316
330
  }
317
331
  },
318
332
  options: { component: this.component, rootData: this.rootData }
319
333
  })
320
334
  }
321
- return entries
335
+ return { computedValueEntries, entriesWithMissingValues }
336
+ }
337
+
338
+ // Returns whether the `if` of the entry's component doesn't evaluate to
339
+ // `false`. Unlike `shouldRenderSchema()`, it doesn't evaluate the components
340
+ // of sections and tabs, as the walk of the data visits them anyway, see
341
+ // `getDataEntries()`, which leads to the same entries.
342
+ isEntryShown(entry) {
343
+ return (
344
+ entry.schema.if === undefined ||
345
+ getSchemaValue('if', {
346
+ type: Boolean,
347
+ schema: entry.schema,
348
+ context: this.createEntryContext(entry),
349
+ default: true
350
+ })
351
+ )
352
+ }
353
+
354
+ // Returns whether the value of the entry is missing and needs its default.
355
+ // The model writes the values of components with `compute()`, including
356
+ // their defaults, see `computeValue()`.
357
+ shouldSetDefaultValue(entry) {
358
+ const { schema, data, name } = entry
359
+ return shouldSetDefaultValue(
360
+ schema,
361
+ data,
362
+ name,
363
+ () => this.createEntryContext(entry),
364
+ { shouldSetDefaultsOfComponentsWithCompute: false }
365
+ )
366
+ }
367
+
368
+ // Sets the defaults of the values of the entries, unless they were set in
369
+ // the meantime.
370
+ setDefaultValues(entries) {
371
+ for (const entry of entries) {
372
+ if (this.shouldSetDefaultValue(entry)) {
373
+ const { schema, data, name } = entry
374
+ data[name] = getDefaultValue(schema, () =>
375
+ this.createEntryContext(entry)
376
+ )
377
+ }
378
+ }
322
379
  }
323
380
 
324
381
  // Keeps the records of the components that are still present with the same
@@ -395,9 +452,10 @@ export class FormModel {
395
452
  }
396
453
 
397
454
  // Returns whether the entry is still one of the current entries, with the
398
- // same schema and data, see `getComputedValueEntries()`.
455
+ // same schema and data, see `getDataEntries()`.
399
456
  isEntryCurrent({ componentPath, schema, data }) {
400
- const currentEntry = this.computedValueEntries.value.get(componentPath)
457
+ const currentEntry =
458
+ this.dataEntries.value.computedValueEntries.get(componentPath)
401
459
  return currentEntry?.schema === schema && currentEntry.data === data
402
460
  }
403
461
 
@@ -466,7 +524,7 @@ export class FormModel {
466
524
  const tokens = isNested(schema)
467
525
  ? parseDataPath(dataPath)
468
526
  : [...parseDataPath(dataPath), name]
469
- return tokens.slice(parseDataPath(this.dataPath).length)
527
+ return tokens.slice(parseDataPath(this.dataPath).length).join('/')
470
528
  }
471
529
 
472
530
  // Returns the context for `if`, data schemas and options, with the component
@@ -497,8 +555,9 @@ function takeOverValuesAtDataPaths(
497
555
  }
498
556
  const result = clone(processedData)
499
557
  for (const dataPath of dataPaths) {
500
- const parentDataPath = dataPath.slice(0, -1)
501
- const key = dataPath.at(-1)
558
+ const tokens = parseDataPath(dataPath)
559
+ const parentDataPath = tokens.slice(0, -1)
560
+ const key = tokens.at(-1)
502
561
  const parent = getValueAtDataPath(result, parentDataPath, () => null)
503
562
  if (parent && typeof parent === 'object') {
504
563
  const value = getValueAtDataPath(
@@ -0,0 +1,70 @@
1
+ import { isAbsoluteUrl } from '@ditojs/utils'
2
+ import { formatQuery } from './route.js'
3
+
4
+ export class RequestError extends Error {
5
+ constructor(response) {
6
+ super(
7
+ `Request failed with status code: ${response.status} (${
8
+ response.statusText
9
+ })`
10
+ )
11
+ this.response = response
12
+ }
13
+ }
14
+
15
+ export async function request(api, {
16
+ url,
17
+ method = 'get',
18
+ query = null,
19
+ headers = null,
20
+ data = null,
21
+ signal = null,
22
+ timeout = api.timeout ?? null
23
+ }) {
24
+ const isApiUrl = api.isApiUrl(url)
25
+ // Abort requests that take longer than the timeout, if there is one, so that
26
+ // nothing waits for them forever, e.g. submitting for pending loads:
27
+ const signals = [signal, timeout && AbortSignal.timeout(timeout)]
28
+ signal = AbortSignal.any(signals.filter(Boolean))
29
+
30
+ const response = await fetch(api.getApiUrl({ url, query }), {
31
+ method: method.toUpperCase(),
32
+ ...(data && { body: JSON.stringify(data) }),
33
+ headers: {
34
+ ...(isApiUrl && api.headers),
35
+ ...headers
36
+ },
37
+ credentials:
38
+ isApiUrl && api.cors?.credentials
39
+ ? 'include'
40
+ : 'same-origin',
41
+ signal
42
+ })
43
+
44
+ if (response.headers.get('Content-Type')?.includes('application/json')) {
45
+ response.data = await response.json()
46
+ }
47
+
48
+ if (!response.ok) {
49
+ throw new RequestError(response)
50
+ }
51
+ return response
52
+ }
53
+
54
+ export function isApiUrl(api, url) {
55
+ return !isAbsoluteUrl(url) || url.startsWith(api.url)
56
+ }
57
+
58
+ export function getApiUrl(api, { url, query }) {
59
+ if (!url.startsWith(api.url) && !isAbsoluteUrl(url)) {
60
+ url = combineUrls(api.url, url)
61
+ }
62
+ // Support optional query parameters, to be are added to the URL.
63
+ const search = formatQuery(query)
64
+ return search ? `${url}${url.includes('?') ? '&' : '?'}${search}` : url
65
+ }
66
+
67
+ function combineUrls(baseUrl, relativeUrl) {
68
+ // Use same approach as axios `combineURLs()` to join baseUrl & relativeUrl:
69
+ return `${baseUrl.replace(/\/+$/, '')}/${relativeUrl.replace(/^\/+/, '')}`
70
+ }
@@ -126,10 +126,12 @@ export function shouldIgnoreMissingValue(schema, context) {
126
126
  // is missing in `data`: if there's no value, or if the value is `null` and
127
127
  // the type treats that as missing, e.g. nested sections.
128
128
  export function isMissingValue(schema, data, name, context) {
129
+ // Only read the value if needed, so that callers that are tracked, e.g. the
130
+ // walk of the form model's data, don't depend on all values.
129
131
  return (
130
132
  !(name in data) ||
131
- data[name] === null &&
132
- !!getTypeOptions(schema)?.treatNullAsMissing?.(getContext(context))
133
+ !!getTypeOptions(schema)?.treatNullAsMissing?.(getContext(context)) &&
134
+ data[name] === null
133
135
  )
134
136
  }
135
137
 
@@ -150,6 +152,22 @@ export function getMultipleValue(schema) {
150
152
  return schema.multiple ?? !!getTypeOptions(schema)?.defaultMultiple
151
153
  }
152
154
 
155
+ /**
156
+ * Returns whether the value of the component described by `schema` and `name`
157
+ * is missing in `data` and needs its default, see `initializeData()`.
158
+ * `shouldSetDefaultsOfComponentsWithCompute` decides for components with
159
+ * `compute()`, which may derive their missing values instead.
160
+ */
161
+ export function shouldSetDefaultValue(schema, data, name, context, {
162
+ shouldSetDefaultsOfComponentsWithCompute = true
163
+ } = {}) {
164
+ return (
165
+ (shouldSetDefaultsOfComponentsWithCompute || !schema.compute) &&
166
+ isMissingValue(schema, data, name, context) &&
167
+ !shouldIgnoreMissingValue(schema, context)
168
+ )
169
+ }
170
+
153
171
  /**
154
172
  * Initializes `data` for the components of `schema`: Sets missing values to
155
173
  * their defaults, so they can be correctly watched for changes, and numbers
@@ -177,11 +195,14 @@ export function initializeData(schema, data = {}, component, {
177
195
  dataPath,
178
196
  rootData
179
197
  })
180
- if (
181
- (shouldSetDefaultsOfComponentsWithCompute || !schema.compute) &&
182
- isMissingValue(schema, data, name, context) &&
183
- !shouldIgnoreMissingValue(schema, context)
184
- ) {
198
+ const shouldSetDefault = shouldSetDefaultValue(
199
+ schema,
200
+ data,
201
+ name,
202
+ context,
203
+ { shouldSetDefaultsOfComponentsWithCompute }
204
+ )
205
+ if (shouldSetDefault) {
185
206
  data[name] = getDefaultValue(schema, context)
186
207
  }
187
208
  if (hasItemsNumberedByOrderKey(schema) && isArray(data[name])) {
@@ -203,8 +224,9 @@ export function initializeData(schema, data = {}, component, {
203
224
  * value in `data`, or its default if it's missing. Never writes into `data`:
204
225
  * Computed values, including the defaults of components with `compute()`, are
205
226
  * written by `FormModel`, and other defaults when the data is set up, see
206
- * `initializeData()`. If provided, `getOptions()` returns the
207
- * options for `context.options`, called only when they're read.
227
+ * `initializeData()`, or when they go missing later, see `FormModel`. If
228
+ * provided, `getOptions()` returns the options for `context.options`, called
229
+ * only when they're read.
208
230
  */
209
231
  export function computeValue(schema, data, name, dataPath, {
210
232
  component = null,
@@ -406,11 +428,19 @@ export function processData(schema, sourceSchema, data, dataPath, {
406
428
  * returns `false` are skipped along with their content. The entries passed to
407
429
  * them contain the `componentPath` of the component, tab or panel, continuing
408
430
  * `componentPath` like `DitoMixin.componentPath` does.
431
+ *
432
+ * The primitive values of lists with `wrapPrimitives` are walked wrapped in
433
+ * objects under the `wrapPrimitives` key, like the admin edits them, see
434
+ * `SourceMixin`. `wrappedPrimitiveName` is that key while walking such an
435
+ * object, and its component keeps the data path of the item, where the admin
436
+ * maps errors of the value to. Values written into the objects don't reach
437
+ * the data, e.g. defaults, as the primitive values themselves are the data.
409
438
  */
410
439
  export function processSchemaData(schema, data, {
411
440
  dataPath = null,
412
441
  componentPath = '',
413
442
  processedData = null,
443
+ wrappedPrimitiveName = null,
414
444
  before = null,
415
445
  after = null,
416
446
  shouldProcess = () => true,
@@ -426,9 +456,11 @@ export function processSchemaData(schema, data, {
426
456
  if (components) {
427
457
  for (const [name, componentSchema] of Object.entries(components)) {
428
458
  const isNestedComponent = isNested(componentSchema)
429
- const componentDataPath = isNestedComponent
430
- ? getDataPath(dataPath, name)
431
- : dataPath
459
+ const isWrappedPrimitive = name === wrappedPrimitiveName
460
+ const componentDataPath =
461
+ isNestedComponent && !isWrappedPrimitive
462
+ ? getDataPath(dataPath, name)
463
+ : dataPath
432
464
  const entry = {
433
465
  schema: componentSchema,
434
466
  data,
@@ -453,12 +485,12 @@ export function processSchemaData(schema, data, {
453
485
  processedData
454
486
  })
455
487
  } else {
488
+ const { wrapPrimitives } = componentSchema
456
489
  const processItem = (item, index = null) => {
457
- if (!isObject(item)) {
458
- // Items that aren't objects can't hold component values, e.g.
459
- // the unwrapped values of lists with `wrapPrimitives` when
460
- // walking data that isn't processed.
461
- return item
490
+ // Data that isn't processed holds the primitive values themselves,
491
+ // processed data holds them wrapped already, see `processData()`.
492
+ if (wrapPrimitives && !isObject(item)) {
493
+ item = { [wrapPrimitives]: item }
462
494
  }
463
495
  const itemDataPath =
464
496
  index !== null
@@ -489,7 +521,8 @@ export function processSchemaData(schema, data, {
489
521
  ...walkOptions,
490
522
  dataPath: itemDataPath,
491
523
  componentPath: itemComponentPath,
492
- processedData: processedItem
524
+ processedData: processedItem,
525
+ wrappedPrimitiveName: wrapPrimitives ?? null
493
526
  })
494
527
  } else {
495
528
  // Items without a matching form, e.g. of an unknown type, are
@@ -500,29 +533,38 @@ export function processSchemaData(schema, data, {
500
533
 
501
534
  before?.(entry)
502
535
 
503
- let value = processedData ? processedData[name] : data[name]
504
- if (value != null && hasFormSchema(componentSchema)) {
505
- // Recursively process data on nested form items.
506
- if (isArray(value)) {
507
- // Optimization: No need to collect values if we're not cloning!
508
- value = processedData
509
- ? value.map(processItem)
510
- : value.forEach(processItem)
511
- } else {
512
- value = processItem(value)
513
- }
514
- if (processedData) {
515
- processedData[name] = value
536
+ // Only read the values of sources with forms, so that callers that
537
+ // are tracked, e.g. the walk of the form model's data, don't depend
538
+ // on all values.
539
+ if (hasFormSchema(componentSchema)) {
540
+ let value = processedData ? processedData[name] : data[name]
541
+ if (value != null) {
542
+ // Recursively process data on nested form items.
543
+ if (isArray(value)) {
544
+ // Optimization: No need to collect values if not cloning!
545
+ value = processedData
546
+ ? value.map(processItem)
547
+ : value.forEach(processItem)
548
+ } else {
549
+ value = processItem(value)
550
+ }
551
+ if (processedData) {
552
+ processedData[name] = value
553
+ }
516
554
  }
517
555
  }
518
556
 
519
557
  after?.(entry)
558
+ // The panels of nested components display the data that contains
559
+ // the components, like the ones of unnested components, see
560
+ // `DitoContainer.panelEntries`.
561
+ processTabOrPanelSchemas(componentSchema.panels, entry.componentPath)
520
562
  }
521
563
  }
522
564
  }
523
565
  }
524
566
 
525
- const processTabOrPanelSchemas = schemas => {
567
+ const processTabOrPanelSchemas = (schemas, parentComponentPath) => {
526
568
  // Tabs and panels add their names to component paths, not to data paths.
527
569
  for (const [name, tabOrPanelSchema] of Object.entries(schemas || {})) {
528
570
  const entry = {
@@ -530,7 +572,7 @@ export function processSchemaData(schema, data, {
530
572
  data,
531
573
  name: null,
532
574
  dataPath,
533
- componentPath: appendDataPath(componentPath, name),
575
+ componentPath: appendDataPath(parentComponentPath, name),
534
576
  processedData
535
577
  }
536
578
  // The components of panels with their own data don't edit `data`.
@@ -541,8 +583,8 @@ export function processSchemaData(schema, data, {
541
583
  }
542
584
 
543
585
  processComponents(schema.components, componentPath)
544
- processTabOrPanelSchemas(schema.tabs)
545
- processTabOrPanelSchemas(schema.panels)
586
+ processTabOrPanelSchemas(schema.tabs, componentPath)
587
+ processTabOrPanelSchemas(schema.panels, componentPath)
546
588
 
547
589
  return processedData || data
548
590
  }
@@ -176,27 +176,10 @@ export function getButtonSchemas(buttons) {
176
176
  )
177
177
  }
178
178
 
179
- export function getPanelEntry(
180
- schema,
181
- dataPath = null,
182
- componentPath = '',
183
- tabComponent = null
184
- ) {
185
- return schema
186
- ? {
187
- schema,
188
- // If the panel provides its own name, append it to the paths. This is
189
- // used e.g. for $filters panels.
190
- dataPath:
191
- dataPath != null && schema.name
192
- ? appendDataPath(dataPath, schema.name)
193
- : dataPath,
194
- componentPath: schema.name
195
- ? appendDataPath(componentPath, schema.name)
196
- : componentPath,
197
- tabComponent
198
- }
199
- : null
179
+ // Returns the entry of the panel described by `schema`, which `DitoPanels`
180
+ // displays at `dataPath` and `componentPath`.
181
+ function getPanelEntry(schema, dataPath, componentPath, tabComponent) {
182
+ return schema ? { schema, dataPath, componentPath, tabComponent } : null
200
183
  }
201
184
 
202
185
  export function getPanelEntries(
@@ -224,8 +207,9 @@ export function getPanelEntries(
224
207
 
225
208
  // Returns the entries of the panel that the type of the component described by
226
209
  // `schema` provides, and of the panels in `schema.panels`. The type's panel is
227
- // addressed relative to `dataPath` and its component path,
228
- // `dataPathComponentPath`, while the panels in `schema.panels` continue
210
+ // addressed by its name, e.g. `$filters`, relative to `dataPath` and its
211
+ // component path, `dataPathComponentPath`, while the panels in `schema.panels`
212
+ // are addressed by their keys, see `getPanelEntries()`, continuing
229
213
  // `schemaComponentPath`, the component path of `schema` itself, which also
230
214
  // contains the names of unnested components.
231
215
  export function getAllPanelEntries(
@@ -247,8 +231,8 @@ export function getAllPanelEntries(
247
231
  ? [
248
232
  getPanelEntry(
249
233
  panelSchema,
250
- dataPath,
251
- dataPathComponentPath,
234
+ appendDataPath(dataPath, panelSchema.name),
235
+ appendDataPath(dataPathComponentPath, panelSchema.name),
252
236
  tabComponent
253
237
  )
254
238
  ]
package/types/index.d.ts CHANGED
@@ -52,6 +52,12 @@ export type RequestMethod = <T>(options: {
52
52
  headers?: Record<string, string> | null
53
53
  /** Abort signal to cancel the request. */
54
54
  signal?: AbortSignal | null
55
+ /**
56
+ * Milliseconds after which the request is aborted.
57
+ *
58
+ * @defaultValue {@link ApiConfig.timeout}
59
+ */
60
+ timeout?: number | null
55
61
  }) => Promise<RequestMethodResponse<T>>
56
62
 
57
63
  /**
@@ -170,6 +176,14 @@ export interface ApiConfig {
170
176
  */
171
177
  durationFactor: number
172
178
  }
179
+ /**
180
+ * Milliseconds after which requests are aborted, so that
181
+ * nothing waits for them forever, e.g. submitting forms for
182
+ * pending loads of options. Requests can set their own.
183
+ *
184
+ * @defaultValue `null`, no timeout
185
+ */
186
+ timeout?: number | null
173
187
  /**
174
188
  * CORS settings applied to API requests (where
175
189
  * {@link ApiConfig.isApiUrl} returns `true`).
@@ -330,6 +344,11 @@ export interface BaseSchema<$Item>
330
344
  * when the field isn't rendered, unless its `if`
331
345
  * evaluates to `false`. If the callback returns
332
346
  * `undefined`, the current value is preserved.
347
+ *
348
+ * As it doesn't depend on the field's component, the
349
+ * context's component properties, e.g. `component`
350
+ * and `schemaComponent`, refer to the form, view or
351
+ * dialog that holds the data, not to the field.
333
352
  */
334
353
  compute?: ItemAccessor<$Item>
335
354
  /**
@@ -797,6 +816,12 @@ export type SchemaOptions<$Item, $Option = any> =
797
816
  * receives the `DitoContext` and returns an inner
798
817
  * function that is called to fetch the actual data,
799
818
  * enabling reactive dependency tracking.
819
+ *
820
+ * The options are loaded independently of the
821
+ * field's component, also for computes, so the
822
+ * context's component properties, e.g. `component`
823
+ * and `schemaComponent`, refer to the form, view or
824
+ * dialog that holds the data, not to the field.
800
825
  */
801
826
  data?: OrItemAccessor<
802
827
  $Item,