@homebound/form-state 2.22.5 → 2.23.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/dist/config.d.ts CHANGED
@@ -65,6 +65,8 @@ export type ListFieldConfig<U> = {
65
65
  };
66
66
  export type ObjectFieldConfig<U> = {
67
67
  type: "object";
68
+ /** Marks an object as a reference, which means we'll only include it's `id` in `changedValue` output. */
69
+ reference?: boolean;
68
70
  /** Config for the child's form state, i.e. each book. */
69
71
  config: ObjectConfig<U>;
70
72
  };
@@ -1,4 +1,4 @@
1
- import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ValueFieldConfig } from "./config";
1
+ import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ObjectFieldConfig, ValueFieldConfig } from "./config";
2
2
  import { Fragment, ObjectState } from "./fields/objectField";
3
3
  import { Rule } from "./rules";
4
4
  import { Builtin, OmitIf } from "./utils";
@@ -14,8 +14,12 @@ export declare const f: {
14
14
  list<U>(fields: ObjectConfigBuilderFields<U>): ListFieldConfigBuilder<U>;
15
15
  /** Creates the config DSL for a primitive value, like a first name or phone number. */
16
16
  value<V>(): ValueFieldConfigBuilder<V>;
17
+ /** A shortcut for creating a child object with a single `id` key. */
18
+ reference<V_1 extends {
19
+ id?: unknown;
20
+ }>(fields?: ObjectConfigBuilderFields<V_1> | undefined): ObjectConfigBuilder<V_1>;
17
21
  /** A shorthand for creating a computed value. */
18
- computed<V_1>(): ValueFieldConfigBuilder<V_1>;
22
+ computed<V_2>(): ValueFieldConfigBuilder<V_2>;
19
23
  };
20
24
  /**
21
25
  * Defines the fields that should be passed into `f.object({ ... })` for fhe form type `T`.
@@ -33,7 +37,8 @@ export interface FragmentFieldConfigBuilder {
33
37
  export declare class ObjectConfigBuilder<T> {
34
38
  private config;
35
39
  constructor(fields: ObjectConfigBuilderFields<T>);
36
- build(): ObjectConfig<T>;
40
+ ref(): this;
41
+ build(): ObjectFieldConfig<T>;
37
42
  }
38
43
  /** Provides a fluent DSL for building up a value's config. */
39
44
  export declare class ValueFieldConfigBuilder<V> {
@@ -8,7 +8,7 @@ const rules_1 = require("./rules");
8
8
  exports.f = {
9
9
  /** Creates the top-level config for a form, like an author or book. */
10
10
  config(fields) {
11
- return new ObjectConfigBuilder(fields).build();
11
+ return new ObjectConfigBuilder(fields).build().config;
12
12
  },
13
13
  /** Creates the config DSL for an object, like an author or book. */
14
14
  object(fields) {
@@ -22,6 +22,10 @@ exports.f = {
22
22
  value() {
23
23
  return new ValueFieldConfigBuilder();
24
24
  },
25
+ /** A shortcut for creating a child object with a single `id` key. */
26
+ reference(fields) {
27
+ return exports.f.object({ id: exports.f.value(), ...fields }).ref();
28
+ },
25
29
  /** A shorthand for creating a computed value. */
26
30
  computed() {
27
31
  return this.value().computed();
@@ -30,11 +34,15 @@ exports.f = {
30
34
  /** Provides a fluent DSL for building up an object's config. */
31
35
  class ObjectConfigBuilder {
32
36
  constructor(fields) {
33
- this.config = {};
37
+ this.config = { type: "object", config: {} };
34
38
  for (const [key, value] of Object.entries(fields)) {
35
- this.config[key] = value.build();
39
+ this.config.config[key] = value.build();
36
40
  }
37
41
  }
42
+ ref() {
43
+ this.config.reference = true;
44
+ return this;
45
+ }
38
46
  build() {
39
47
  return this.config;
40
48
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const configBuilders_1 = require("./configBuilders");
4
+ describe("config", () => {
5
+ it("supports observable objects with helper methods in the config DSL", () => {
6
+ const config = configBuilders_1.f.config({
7
+ firstName: configBuilders_1.f.value(),
8
+ lastName: configBuilders_1.f.value(),
9
+ fullName: configBuilders_1.f.computed(),
10
+ });
11
+ // Throw away assertion, test is making sure ^ line compiles
12
+ expect(config).toBeDefined();
13
+ });
14
+ it("supports nested objects", () => {
15
+ const config = configBuilders_1.f.config({
16
+ id: configBuilders_1.f.value(),
17
+ address: configBuilders_1.f.object({ id: configBuilders_1.f.value() }),
18
+ });
19
+ expect(config).toMatchInlineSnapshot(`
20
+ {
21
+ "address": {
22
+ "config": {
23
+ "id": {
24
+ "rules": [],
25
+ "type": "value",
26
+ },
27
+ },
28
+ "type": "object",
29
+ },
30
+ "id": {
31
+ "rules": [],
32
+ "type": "value",
33
+ },
34
+ }
35
+ `);
36
+ });
37
+ it("supports Reference alias", () => {
38
+ const config = configBuilders_1.f.config({
39
+ id: configBuilders_1.f.value(),
40
+ address: configBuilders_1.f.reference({ name: configBuilders_1.f.value() }),
41
+ });
42
+ expect(config).toMatchInlineSnapshot(`
43
+ {
44
+ "address": {
45
+ "config": {
46
+ "id": {
47
+ "rules": [],
48
+ "type": "value",
49
+ },
50
+ "name": {
51
+ "rules": [],
52
+ "type": "value",
53
+ },
54
+ },
55
+ "reference": true,
56
+ "type": "object",
57
+ },
58
+ "id": {
59
+ "rules": [],
60
+ "type": "value",
61
+ },
62
+ }
63
+ `);
64
+ });
65
+ });
@@ -1,4 +1,4 @@
1
- import { ListFieldConfig, ObjectConfig } from "../config";
1
+ import { ListFieldConfig, ObjectFieldConfig } from "../config";
2
2
  import { ObjectState } from "./objectField";
3
3
  import { FieldState } from "./valueField";
4
4
  import { Rule } from "../rules";
@@ -8,4 +8,4 @@ export interface ListFieldState<U> extends FieldState<U[]> {
8
8
  add(value: U, index?: number): void;
9
9
  remove(indexOrValue: number | U): void;
10
10
  }
11
- export declare function newListFieldState<T, K extends keyof T, U>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
11
+ export declare function newListFieldState<T, K extends keyof T, U>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectFieldConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
@@ -41,7 +41,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
41
41
  }
42
42
  // If we didn't have an existing child, just make a new object state
43
43
  if (!childState) {
44
- childState = (0, objectField_1.newObjectState)(config, parentState, list, child, undefined, maybeAutoSave);
44
+ childState = (0, objectField_1.newObjectState)(config, parentState, undefined, list, child, undefined, maybeAutoSave);
45
45
  rowMap.set(child, childState);
46
46
  }
47
47
  return childState;
@@ -181,7 +181,8 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
181
181
  },
182
182
  add(value, spliceIndex) {
183
183
  // This is called by the user, so value should be a non-proxy value we should keep
184
- const childState = (0, objectField_1.createObjectState)(config, value, { maybeAutoSave });
184
+ const childState = getOrCreateChildState(value);
185
+ // const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
185
186
  rowMap.set(value, childState);
186
187
  this.ensureSet();
187
188
  this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
@@ -1,4 +1,4 @@
1
- import { ObjectConfig } from "../config";
1
+ import { ObjectConfig, ObjectFieldConfig } from "../config";
2
2
  import { FragmentField } from "./fragmentField";
3
3
  import { ListFieldState } from "./listField";
4
4
  import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
@@ -57,5 +57,6 @@ type FieldStates<T> = {
57
57
  export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
58
58
  maybeAutoSave?: () => void;
59
59
  }): ObjectState<T>;
60
- export declare function newObjectState<T, P = any>(config: ObjectConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T>;
60
+ /** A more internal version of `createObjectState`. */
61
+ export declare function newObjectState<T, P = any>(config: ObjectFieldConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentInstance: P | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T>;
61
62
  export {};
@@ -20,10 +20,11 @@ exports.fragment = fragment;
20
20
  */
21
21
  function createObjectState(config, instance, opts = {}) {
22
22
  const noop = () => { };
23
- return newObjectState(config, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
23
+ return newObjectState({ type: "object", config }, undefined, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
24
24
  }
25
25
  exports.createObjectState = createObjectState;
26
- function newObjectState(config, parentState, parentListState, instance, key, maybeAutoSave) {
26
+ /** A more internal version of `createObjectState`. */
27
+ function newObjectState(config, parentState, parentInstance, parentListState, instance, key, maybeAutoSave) {
27
28
  // This is what we return, but we only know it's value until we call `observable`, so
28
29
  // we create a mutable variable to capture it so that we can create fields/call their
29
30
  // constructors and give them a way to access it later.
@@ -38,8 +39,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
38
39
  // If the user passed us `instance` that was a mobx store/class, this originalValue won't really
39
40
  // be a true match (i.e. pass instanceof), but it should be good enough
40
41
  const originalCopy = (0, utils_1.deepClone)(instance);
41
- const objectConfig = config;
42
- const fieldStates = Object.entries(config).map(([_key, _config]) => {
42
+ const objectConfig = config.config;
43
+ const fieldStates = Object.entries(objectConfig).map(([_key, _config]) => {
43
44
  var _a, _b, _c, _d;
44
45
  const key = _key;
45
46
  const config = _config;
@@ -53,26 +54,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
53
54
  ((0, mobx_1.isObservable)(instance) ? !(key in originalCopy) : false), (_b = config.readOnly) !== null && _b !== void 0 ? _b : false, (_c = config.strictOrder) !== null && _c !== void 0 ? _c : true, maybeAutoSave);
54
55
  }
55
56
  else if (config.type === "list") {
56
- field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, config.config, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
57
+ field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, { type: "object", config: config.config }, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
57
58
  }
58
59
  else if (config.type === "object") {
59
60
  if (!instance[key]) {
60
61
  instance[key] = {};
61
62
  }
62
- field = newObjectState(config.config, getObjectState, undefined, instance[key], key, maybeAutoSave);
63
+ field = newObjectState(config, getObjectState, instance, undefined, instance[key], key, maybeAutoSave);
63
64
  }
64
65
  else if (config.type === "fragment") {
65
66
  field = (0, fragmentField_1.newFragmentField)(instance, key);
66
67
  }
67
68
  else {
68
- throw new Error("Unsupported");
69
+ throw new Error(`Invalid type value ${config.type}`);
69
70
  }
70
71
  return [key, field];
71
72
  });
72
73
  // We always return the same `instance` field from our `value` method, but
73
74
  // we want to pretend that it's observable, so use a tick to force it.
74
75
  const _tick = (0, mobx_1.observable)({ value: 1 });
75
- const fieldNames = Object.keys(config);
76
+ const fieldNames = Object.keys(objectConfig);
76
77
  function getFields(proxyThis) {
77
78
  return fieldNames.map((name) => proxyThis[name]);
78
79
  }
@@ -87,6 +88,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
87
88
  this.set(value);
88
89
  },
89
90
  // private
91
+ _kind: "object",
90
92
  _readOnly: false,
91
93
  _loading: false,
92
94
  _considerDeleted() {
@@ -128,7 +130,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
128
130
  return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
129
131
  },
130
132
  get dirty() {
131
- return getFields(this).some((f) => f.dirty);
133
+ return getFields(this).some((f) => f.dirty) || this.isUnset();
132
134
  },
133
135
  get isNewEntity() {
134
136
  const idField = getFields(this).find((f) => f._isIdKey);
@@ -148,6 +150,13 @@ function newObjectState(config, parentState, parentListState, instance, key, may
148
150
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
149
151
  throw new Error(`${String(key) || "formState"} is currently readOnly`);
150
152
  }
153
+ // Restore our instance if we're being reset
154
+ if (value && this.isUnset())
155
+ parentInstance[key] = instance;
156
+ // Delete our instance if we're being unset
157
+ if (!value && parentInstance)
158
+ parentInstance[key] = undefined;
159
+ // Otherwise just copy over the fields
151
160
  getFields(this).forEach((field) => {
152
161
  if (value && typeof value === "object" && field.key in value) {
153
162
  field.set(value[field.key], opts);
@@ -165,17 +174,27 @@ function newObjectState(config, parentState, parentListState, instance, key, may
165
174
  // Create a result that is only populated with changed keys
166
175
  get changedValue() {
167
176
  const result = {};
177
+ // If we're a child, like `author.address` that was unset, return null
178
+ if (this.isUnset()) {
179
+ // If we're a reference, always return `{ id: ... }` for easy binding
180
+ const idField = getFields(this).find((f) => f._isIdKey);
181
+ if (idField && config.reference)
182
+ return { id: null };
183
+ // Otherwise mark the whole child as gone
184
+ return null;
185
+ }
168
186
  getFields(this).forEach((f) => {
169
187
  if (f._computed)
170
188
  return;
189
+ // References only include the id key below
190
+ if (config.reference)
191
+ return;
171
192
  if (f.dirty ||
172
193
  // If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
173
194
  // look dirty, but if we're new we should include them anyway.
174
- (this.isNewEntity &&
175
- // Unless they're undefined anyway
176
- f.value !== undefined &&
177
- // And unless they're empty sub-objects
178
- !(f.value instanceof Object && Object.entries(f.changedValue).length === 0))) {
195
+ (this.isNewEntity && f._kind === "value" && f.value !== undefined) ||
196
+ // And unless they're empty sub-objects
197
+ (this.isNewEntity && f._kind === "object" && Object.entries(f.changedValue).length > 0)) {
179
198
  result[f.key] = f.changedValue;
180
199
  }
181
200
  });
@@ -200,6 +219,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
200
219
  // If the otherIdValue is undefined, it's a new entity so can't be the same as us
201
220
  return otherIdValue !== undefined && otherIdValue === ourIdValue;
202
221
  },
222
+ isUnset() {
223
+ return !!parentInstance && parentInstance[key] === undefined;
224
+ },
203
225
  };
204
226
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
205
227
  // Use custom equality on `value` that is _never_ equals. This sounds weird, but
@@ -17,6 +17,7 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
17
17
  /** Current readOnly value. */
18
18
  _readOnly: readOnly || false,
19
19
  _loading: false,
20
+ _kind: "value",
20
21
  // Expose so computed can be skipped in changedValue
21
22
  _computed: computed,
22
23
  _focused: false,
@@ -957,24 +957,6 @@ describe("formState", () => {
957
957
  // Then the formState saw it
958
958
  expect(lastLength).toEqual(2);
959
959
  });
960
- it("supports observable objects with helper methods", () => {
961
- const config = {
962
- firstName: { type: "value" },
963
- lastName: { type: "value" },
964
- fullName: { type: "value", computed: true },
965
- };
966
- // Throw away assertion, test is making sure ^ line compiles
967
- expect(config).toBeDefined();
968
- });
969
- it("supports observable objects with helper methods in the config DSL", () => {
970
- const config = configBuilders_1.f.config({
971
- firstName: configBuilders_1.f.value(),
972
- lastName: configBuilders_1.f.value(),
973
- fullName: configBuilders_1.f.computed(),
974
- });
975
- // Throw away assertion, test is making sure ^ line compiles
976
- expect(config).toBeDefined();
977
- });
978
960
  it("can reset observable objects with computeds", () => {
979
961
  const formState = (0, objectField_1.createObjectState)({
980
962
  firstName: { type: "value" },
@@ -1087,6 +1069,89 @@ describe("formState", () => {
1087
1069
  // because this might trigger creating a throw-away entity
1088
1070
  expect(formState.changedValue).toEqual({ firstName: "f" });
1089
1071
  });
1072
+ it("changedValue includes new entity nested fields", () => {
1073
+ // Given a new author with an address FK
1074
+ const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, {
1075
+ firstName: "f",
1076
+ address: { id: "add:1" },
1077
+ });
1078
+ // Then changedValue includes the reference to the FK
1079
+ expect(formState.changedValue).toEqual({
1080
+ firstName: "f",
1081
+ address: { id: "add:1" },
1082
+ });
1083
+ });
1084
+ it("changedValue includes new entity nested fields with multiple keys", () => {
1085
+ // Given a new author with an address FK
1086
+ const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, { firstName: "f" });
1087
+ formState.address.set({ id: "add:1", street: "Main St" });
1088
+ // Then changedValue includes the reference to the FK
1089
+ expect(formState.changedValue).toEqual({
1090
+ firstName: "f",
1091
+ address: { id: "add:1", street: "Main St" },
1092
+ });
1093
+ });
1094
+ it("changedValue includes deleted nested fields", () => {
1095
+ var _a;
1096
+ // Given a new author with an address FK
1097
+ const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
1098
+ id: configBuilders_1.f.value(),
1099
+ firstName: configBuilders_1.f.value(),
1100
+ // And address is a reference
1101
+ address: configBuilders_1.f.reference(),
1102
+ }), { id: "a:1", firstName: "f", address: { id: "add:1" } });
1103
+ // When the address is unset
1104
+ formState.address.set(undefined);
1105
+ // Then the field is dirty and will be removed in changedValue
1106
+ expect(formState.address.dirty).toBe(true);
1107
+ expect(formState.dirty).toBe(true);
1108
+ expect(formState.changedValue).toEqual({
1109
+ id: "a:1",
1110
+ address: { id: null },
1111
+ });
1112
+ // So that we can do a binding like
1113
+ const addressId = (_a = formState.changedValue.address) === null || _a === void 0 ? void 0 : _a.id;
1114
+ expect(addressId).toBeNull();
1115
+ // And when we're restored to the same value
1116
+ formState.address.set({ id: "add:1" });
1117
+ // Then it's back to not being dirty
1118
+ expect(formState.address.dirty).toBe(false);
1119
+ expect(formState.dirty).toBe(false);
1120
+ // And when we're changed to a different address
1121
+ formState.address.set({ id: "add:2" });
1122
+ // Then we're dirty again
1123
+ expect(formState.changedValue).toEqual({
1124
+ id: "a:1",
1125
+ address: { id: "add:2" },
1126
+ });
1127
+ });
1128
+ it("changedValue includes deleted nested fields with a name", () => {
1129
+ // Given a new author with an address FK
1130
+ const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
1131
+ id: configBuilders_1.f.value(),
1132
+ firstName: configBuilders_1.f.value(),
1133
+ address: configBuilders_1.f.reference({ street: configBuilders_1.f.value() }),
1134
+ }), { id: "a:1", firstName: "f", address: { id: "add:1", street: "Main St" } });
1135
+ // Initially both id and street can be bound to the UI
1136
+ expect(formState.address.id.value).toBe("add:1");
1137
+ expect(formState.address.street.value).toBe("Main St");
1138
+ // When the address is unset
1139
+ formState.address.set(undefined);
1140
+ // Then the field is dirty and will be removed in changedValue
1141
+ expect(formState.address.dirty).toBe(true);
1142
+ // And our changedValue only includes the id
1143
+ expect(formState.changedValue).toEqual({
1144
+ id: "a:1",
1145
+ address: { id: null },
1146
+ });
1147
+ // And when we're changed to a new address
1148
+ formState.address.set({ id: "add:2", street: "Side St" });
1149
+ // Then we see oly the id come back in changedValue
1150
+ expect(formState.changedValue).toEqual({
1151
+ id: "a:1",
1152
+ address: { id: "add:2" },
1153
+ });
1154
+ });
1090
1155
  it("changedValue skips effectively empty nested fields", () => {
1091
1156
  // Given a new author with an address object
1092
1157
  const formState = (0, objectField_1.createObjectState)(authorWithAddressConfig, {
@@ -1620,6 +1685,19 @@ const authorWithAddressConfig = {
1620
1685
  },
1621
1686
  },
1622
1687
  };
1688
+ const authorWithAddressFkConfig = {
1689
+ id: { type: "value" },
1690
+ firstName: { type: "value" },
1691
+ lastName: { type: "value" },
1692
+ address: {
1693
+ type: "object",
1694
+ config: {
1695
+ id: { type: "value" },
1696
+ street: { type: "value", rules: [rules_1.required] },
1697
+ city: { type: "value" },
1698
+ },
1699
+ },
1700
+ };
1623
1701
  const authorWithAddressAndBooksConfig = configBuilders_1.f.config({
1624
1702
  id: configBuilders_1.f.value(),
1625
1703
  firstName: configBuilders_1.f.value(),
@@ -18,6 +18,7 @@ export interface AuthorInput {
18
18
  favoriteColors?: Color[] | null;
19
19
  }
20
20
  export interface AuthorAddress {
21
+ id?: string | null;
21
22
  street?: string | null;
22
23
  city?: string | null;
23
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.22.5",
3
+ "version": "2.23.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",