@getaster/data 0.3.5 → 0.4.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.
@@ -15,6 +15,24 @@ export type Rule = (values: Entity) => Readonly<Record<string, string | undefine
15
15
  export interface EditOptions {
16
16
  readonly rules?: readonly Rule[];
17
17
  }
18
+ /** Static defaults for one field. A page rule that names an effect overrides it. */
19
+ export interface EditFieldPolicy {
20
+ readonly readOnly?: boolean;
21
+ readonly disabled?: boolean;
22
+ readonly required?: boolean;
23
+ }
24
+ /**
25
+ * Static edit defaults, applied through [[method:EditModel.applyPolicy]].
26
+ * They are defaults, not a second rule system: an effect a page rule sets
27
+ * wins, and the metadata's ceilings (computed, immutable, non-nullable)
28
+ * still apply. A form's presentation configuration carries them.
29
+ */
30
+ export interface EditPolicy {
31
+ readonly readOnly?: boolean;
32
+ readonly disabled?: boolean;
33
+ /** Per-field defaults, merged over the form-wide entries. */
34
+ readonly fields?: Readonly<Record<string, EditFieldPolicy>>;
35
+ }
18
36
  /**
19
37
  * A field's page rules. With `clearWhenHidden`, hiding a shown field clears
20
38
  * its value. `derived` gives the field a value from the page: cases for
@@ -114,6 +132,20 @@ export declare class EditModel {
114
132
  * `explain`. Call once, before the fields are shown.
115
133
  */
116
134
  applyRules(scope: PageScope, target: string, rules: Readonly<Record<string, FieldRules>>): void;
135
+ /**
136
+ * Static edit defaults, such as a form that is locked for viewing. They
137
+ * are defaults: a page rule that names an effect overrides it, and the
138
+ * metadata still refuses edits to computed or immutable fields. Passing
139
+ * `{}` clears them. Call it before or after `applyRules`; the state is
140
+ * recomputed for whoever reads it.
141
+ */
142
+ applyPolicy(policy: EditPolicy): void;
143
+ /**
144
+ * Checks one field and shows or clears its error, leaving the other
145
+ * fields' errors alone. This is the per-field validation behind
146
+ * `validateOn: "change"` and `"exit"`; `validate()` checks everything.
147
+ */
148
+ validateField(name: string): boolean;
117
149
  /**
118
150
  * Whether the field holds its derived value: suggested or replaced, not
119
151
  * typed over. False for a field without one.
@@ -122,9 +154,10 @@ export declare class EditModel {
122
154
  /** The page rules behind a field's state, with what they read now. */
123
155
  explain(name: string): Explanation[];
124
156
  /**
125
- * A field's state: its page rules, then what the metadata and the record
126
- * say. Computed fields are read-only, immutable ones once saved, and every
127
- * field of a deleted record; a non-nullable field is required.
157
+ * A field's state: static policy for effects no page rule names, then the
158
+ * page rules, then what the metadata and the record say. Computed fields
159
+ * are read-only, immutable ones once saved, and every field of a deleted
160
+ * record; a non-nullable field is required.
128
161
  */
129
162
  state(name: string): ReadonlySignal<FieldState>;
130
163
  pending(name: string): ReadonlySignal<boolean>;
@@ -6,6 +6,8 @@ import { DEFAULT_STATE } from "../scope/scope.js";
6
6
  import { keyFromText } from "../store/keys.js";
7
7
  import { validateEntity } from "../validate/validate.js";
8
8
  const NONE = new Set();
9
+ const NO_EFFECTS = new Set();
10
+ const EFFECTS = ["visible", "enabled", "readOnly", "required"];
9
11
  /**
10
12
  * Uncommitted values for one record, apart from the stored record.
11
13
  *
@@ -42,6 +44,9 @@ export class EditModel {
42
44
  #writes = [];
43
45
  #pending = new Map();
44
46
  #ruled = signal(new Map());
47
+ /** The effects each field's page rules name; an unnamed effect yields to static policy. */
48
+ #effects = new Map();
49
+ #policy = signal({});
45
50
  #states = new Map();
46
51
  #disposers = [];
47
52
  /** A past change shown over its old values: read-only, never saved. */
@@ -194,6 +199,10 @@ export class EditModel {
194
199
  this.#errors.value = { ...this.#errors.peek(), [name]: [state.reasons.readOnly ?? `${this.#title(name)} is read-only.`] };
195
200
  return;
196
201
  }
202
+ if (!state.enabled) {
203
+ this.#errors.value = { ...this.#errors.peek(), [name]: [state.reasons.enabled ?? `${this.#title(name)} is disabled.`] };
204
+ return;
205
+ }
197
206
  batch(() => {
198
207
  this.#values.value = { ...this.#values.peek(), [name]: value };
199
208
  if (this.#derived.has(name) && !this.#typed.peek().has(name))
@@ -214,6 +223,11 @@ export class EditModel {
214
223
  const ruled = new Map(this.#ruled.peek());
215
224
  for (const [name, { clearWhenHidden: _, derived, ...state }] of Object.entries(rules)) {
216
225
  const field = this.#field(name);
226
+ const effects = new Set();
227
+ for (const effect of EFFECTS)
228
+ if (rules[name][effect] !== undefined)
229
+ effects.add(effect);
230
+ this.#effects.set(name, effects);
217
231
  ruled.set(name, scope.state(`${target}.${name}`, state));
218
232
  if (derived === undefined)
219
233
  continue;
@@ -251,6 +265,32 @@ export class EditModel {
251
265
  }));
252
266
  }
253
267
  }
268
+ /**
269
+ * Static edit defaults, such as a form that is locked for viewing. They
270
+ * are defaults: a page rule that names an effect overrides it, and the
271
+ * metadata still refuses edits to computed or immutable fields. Passing
272
+ * `{}` clears them. Call it before or after `applyRules`; the state is
273
+ * recomputed for whoever reads it.
274
+ */
275
+ applyPolicy(policy) {
276
+ this.#policy.value = policy;
277
+ }
278
+ /**
279
+ * Checks one field and shows or clears its error, leaving the other
280
+ * fields' errors alone. This is the per-field validation behind
281
+ * `validateOn: "change"` and `"exit"`; `validate()` checks everything.
282
+ */
283
+ validateField(name) {
284
+ this.#field(name);
285
+ const now = this.#check(this.#values.peek())[name];
286
+ const rest = { ...this.#errors.peek() };
287
+ if (now === undefined)
288
+ delete rest[name];
289
+ else
290
+ rest[name] = now;
291
+ this.#errors.value = rest;
292
+ return now === undefined;
293
+ }
254
294
  /**
255
295
  * Whether the field holds its derived value: suggested or replaced, not
256
296
  * typed over. False for a field without one.
@@ -325,9 +365,10 @@ export class EditModel {
325
365
  return rules.map((r) => r.effect === "derived" ? { ...r, overridden: this.#typed.peek().has(name), current: this.#values.peek()[name] } : r);
326
366
  }
327
367
  /**
328
- * A field's state: its page rules, then what the metadata and the record
329
- * say. Computed fields are read-only, immutable ones once saved, and every
330
- * field of a deleted record; a non-nullable field is required.
368
+ * A field's state: static policy for effects no page rule names, then the
369
+ * page rules, then what the metadata and the record say. Computed fields
370
+ * are read-only, immutable ones once saved, and every field of a deleted
371
+ * record; a non-nullable field is required.
331
372
  */
332
373
  state(name) {
333
374
  let state = this.#states.get(name);
@@ -335,8 +376,15 @@ export class EditModel {
335
376
  const field = this.#field(name);
336
377
  state = computed(() => {
337
378
  const ruled = this.#ruled.value.get(name)?.value ?? DEFAULT_STATE;
379
+ const owned = this.#effects.get(name) ?? NO_EFFECTS;
380
+ const policy = this.#policy.value;
381
+ const own = policy.fields?.[name];
338
382
  const reasons = { ...ruled.reasons };
339
- let readOnly = ruled.readOnly;
383
+ const policyReadOnly = own?.readOnly ?? policy.readOnly ?? false;
384
+ const policyDisabled = own?.disabled ?? policy.disabled ?? false;
385
+ const policyRequired = own?.required ?? false;
386
+ let readOnly = owned.has("readOnly") ? ruled.readOnly : policyReadOnly;
387
+ let enabled = owned.has("enabled") ? ruled.enabled : !policyDisabled;
340
388
  if (this.#replay) {
341
389
  readOnly = true;
342
390
  reasons.readOnly = "This is a past change.";
@@ -355,8 +403,12 @@ export class EditModel {
355
403
  ? `${this.#title(name)} is set by the server.`
356
404
  : `${this.#title(name)} can't be changed once it's set.`;
357
405
  }
358
- const required = ruled.required || (!field.nullable && !field.computed);
359
- return Object.freeze({ ...ruled, readOnly, required, reasons: Object.freeze(reasons) });
406
+ if (!owned.has("readOnly") && policyReadOnly)
407
+ reasons.readOnly ??= "Read-only.";
408
+ if (!owned.has("enabled") && policyDisabled)
409
+ reasons.enabled ??= "Disabled.";
410
+ const required = (owned.has("required") ? ruled.required : policyRequired) || (!field.nullable && !field.computed);
411
+ return Object.freeze({ ...ruled, readOnly, enabled, required, reasons: Object.freeze(reasons) });
360
412
  });
361
413
  this.#states.set(name, state);
362
414
  }
@@ -414,11 +466,14 @@ export class EditModel {
414
466
  const mode = untracked(() => this.isNew.value) ? "create" : "update";
415
467
  const changed = mode === "create" ? values : this.#changed(values);
416
468
  const errors = { ...validateEntity(this.type, changed, mode) };
417
- for (const name of this.#ruled.value.keys()) {
418
- const state = this.state(name).value;
419
- if (state.required && (values[name] ?? null) === null && !errors[name] && !this.#hidden.value.has(name)) {
469
+ for (const field of this.type.fields) {
470
+ const name = field.name;
471
+ if (field.computed || this.#hidden.value.has(name))
472
+ continue;
473
+ if ((values[name] ?? null) !== null || errors[name])
474
+ continue;
475
+ if (this.state(name).value.required)
420
476
  errors[name] = [`${this.#title(name)} is required.`];
421
- }
422
477
  }
423
478
  const ruled = new Set();
424
479
  for (const rule of this.#rules) {
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { Collection, type CollectionOptions, type CollectionStatus } from "./col
21
21
  export { aggregate, countBy, countWhere, subtractExact, sum, sumValues } from "./collection/aggregate.ts";
22
22
  export { apply, creatable, diff, invert, merge, writable, type ChangeSet, type FieldChange, type OwnedChanges } from "./changes/changeset.ts";
23
23
  export { validateEntity, validateField, type FieldErrors } from "./validate/validate.ts";
24
- export { EditModel, type Conflict, type EditDraft, type EditOptions, type FieldRules, type Rule } from "./edit/edit-model.ts";
24
+ export { EditModel, type Conflict, type EditDraft, type EditFieldPolicy, type EditOptions, type EditPolicy, type FieldRules, type Rule } from "./edit/edit-model.ts";
25
25
  export { DataContext, type Saved } from "./context.ts";
26
26
  export { Selection, selectionOptionsError, type SelectionOptions } from "./selection/selection.ts";
27
27
  export { PageScope, toCriteria, DEFAULT_STATE, MAX_HOPS, type Case, type Condition, type Effect, type Explanation, type FieldState, type Publication, type Ruled, type ScopeName, type ScopeSchema, type SelectedValue, type StateRules, } from "./scope/scope.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getaster/data",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {