@servicetitan/form-state 22.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.d.ts +12 -0
  3. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.d.ts.map +1 -0
  4. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.js +89 -0
  5. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.js.map +1 -0
  6. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.stories.d.ts +11 -0
  7. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.stories.d.ts.map +1 -0
  8. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.stories.js +55 -0
  9. package/dist/async-lazy-dropdown-state/async-lazy-dropdown-state.stories.js.map +1 -0
  10. package/dist/async-lazy-dropdown-state/index.d.ts +3 -0
  11. package/dist/async-lazy-dropdown-state/index.d.ts.map +1 -0
  12. package/dist/async-lazy-dropdown-state/index.js +3 -0
  13. package/dist/async-lazy-dropdown-state/index.js.map +1 -0
  14. package/dist/async-lazy-dropdown-state/use-async-lazy-dropdown-state.d.ts +5 -0
  15. package/dist/async-lazy-dropdown-state/use-async-lazy-dropdown-state.d.ts.map +1 -0
  16. package/dist/async-lazy-dropdown-state/use-async-lazy-dropdown-state.js +26 -0
  17. package/dist/async-lazy-dropdown-state/use-async-lazy-dropdown-state.js.map +1 -0
  18. package/dist/date-range.d.ts +5 -0
  19. package/dist/date-range.d.ts.map +1 -0
  20. package/dist/date-range.js +2 -0
  21. package/dist/date-range.js.map +1 -0
  22. package/dist/demo/dropdown-state.d.ts +3 -0
  23. package/dist/demo/dropdown-state.d.ts.map +1 -0
  24. package/dist/demo/dropdown-state.js +133 -0
  25. package/dist/demo/dropdown-state.js.map +1 -0
  26. package/dist/demo/index.d.ts +2 -0
  27. package/dist/demo/index.d.ts.map +1 -0
  28. package/dist/demo/index.js +2 -0
  29. package/dist/demo/index.js.map +1 -0
  30. package/dist/dropdown-state.d.ts +42 -0
  31. package/dist/dropdown-state.d.ts.map +1 -0
  32. package/dist/dropdown-state.js +314 -0
  33. package/dist/dropdown-state.js.map +1 -0
  34. package/dist/form-helpers.d.ts +70 -0
  35. package/dist/form-helpers.d.ts.map +1 -0
  36. package/dist/form-helpers.js +232 -0
  37. package/dist/form-helpers.js.map +1 -0
  38. package/dist/form-validators.d.ts +30 -0
  39. package/dist/form-validators.d.ts.map +1 -0
  40. package/dist/form-validators.js +56 -0
  41. package/dist/form-validators.js.map +1 -0
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +6 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/persistent-form-state/domain-storage.d.ts +14 -0
  47. package/dist/persistent-form-state/domain-storage.d.ts.map +1 -0
  48. package/dist/persistent-form-state/domain-storage.js +42 -0
  49. package/dist/persistent-form-state/domain-storage.js.map +1 -0
  50. package/dist/persistent-form-state/in-memory-storage.d.ts +13 -0
  51. package/dist/persistent-form-state/in-memory-storage.d.ts.map +1 -0
  52. package/dist/persistent-form-state/in-memory-storage.js +30 -0
  53. package/dist/persistent-form-state/in-memory-storage.js.map +1 -0
  54. package/dist/persistent-form-state/index.d.ts +2 -0
  55. package/dist/persistent-form-state/index.d.ts.map +1 -0
  56. package/dist/persistent-form-state/index.js +2 -0
  57. package/dist/persistent-form-state/index.js.map +1 -0
  58. package/dist/persistent-form-state/persistent-form-state.d.ts +18 -0
  59. package/dist/persistent-form-state/persistent-form-state.d.ts.map +1 -0
  60. package/dist/persistent-form-state/persistent-form-state.js +93 -0
  61. package/dist/persistent-form-state/persistent-form-state.js.map +1 -0
  62. package/package.json +45 -0
  63. package/src/__tests__/__snapshots__/form-helpers.test.ts.snap +37 -0
  64. package/src/__tests__/form-helpers.test.ts +229 -0
  65. package/src/__tests__/form-validators.test.ts +55 -0
  66. package/src/async-lazy-dropdown-state/async-lazy-dropdown-state.stories.tsx +66 -0
  67. package/src/async-lazy-dropdown-state/async-lazy-dropdown-state.ts +77 -0
  68. package/src/async-lazy-dropdown-state/index.ts +2 -0
  69. package/src/async-lazy-dropdown-state/use-async-lazy-dropdown-state.ts +38 -0
  70. package/src/date-range.ts +4 -0
  71. package/src/demo/dropdown-state.tsx +233 -0
  72. package/src/demo/index.ts +1 -0
  73. package/src/dropdown-state.ts +205 -0
  74. package/src/form-helpers.ts +259 -0
  75. package/src/form-validators.ts +106 -0
  76. package/src/index.ts +5 -0
  77. package/src/persistent-form-state/__tests__/domain-storage.test.ts +81 -0
  78. package/src/persistent-form-state/domain-storage.ts +43 -0
  79. package/src/persistent-form-state/in-memory-storage.ts +32 -0
  80. package/src/persistent-form-state/index.ts +1 -0
  81. package/src/persistent-form-state/persistent-form-state.ts +68 -0
@@ -0,0 +1,229 @@
1
+ import { FormState, FieldState } from 'formstate';
2
+ import {
3
+ commitFormState,
4
+ formStateToJS,
5
+ BAD_formStateToJS,
6
+ InputFieldState,
7
+ traverseFormState,
8
+ setFormStateValues,
9
+ } from '../form-helpers';
10
+
11
+ const getFormState = () =>
12
+ new FormState({
13
+ field: new InputFieldState(0),
14
+ subForm: new FormState({
15
+ subField: new InputFieldState(1),
16
+ subSubForm: new FormState({
17
+ subSubField: new InputFieldState(1),
18
+ subSubField2: new InputFieldState(2),
19
+ }),
20
+ subSubFormArray: new FormState([
21
+ new FormState({
22
+ veryNestedField: new FieldState(1),
23
+ }),
24
+ ]),
25
+ subSubFormMap: new FormState(
26
+ new Map([
27
+ ['subSubMapField', new FieldState('x')],
28
+ ['subSubMapField2', new FieldState('y')],
29
+ ])
30
+ ),
31
+ }),
32
+ subFormArray: new FormState([
33
+ new FormState({
34
+ a: new InputFieldState(1),
35
+ }),
36
+ ]),
37
+ subFormArray2: new FormState([new InputFieldState(1), new InputFieldState(2)]),
38
+ subFormMap: new FormState(
39
+ new Map([
40
+ ['subMapField', new FieldState('one')],
41
+ ['subMapField2', new FieldState('two')],
42
+ ['subMapField3', new FieldState('three')],
43
+ ])
44
+ ),
45
+ });
46
+
47
+ test('traverseFormState recursive', () => {
48
+ const form = getFormState();
49
+ const traverseResult: string[] = [];
50
+ traverseFormState(
51
+ true,
52
+ form,
53
+ key => {
54
+ traverseResult.push(key);
55
+ },
56
+ key => {
57
+ traverseResult.push(key);
58
+ }
59
+ );
60
+
61
+ expect(traverseResult).toContain('field');
62
+ expect(traverseResult).toContain('subForm');
63
+ expect(traverseResult).toContain('subField');
64
+ expect(traverseResult).toContain('veryNestedField');
65
+ expect(traverseResult).toContain('subSubMapField');
66
+ });
67
+
68
+ test('traverseFormState non recursive', () => {
69
+ const form = getFormState();
70
+ const traverseResult: string[] = [];
71
+ traverseFormState(
72
+ false,
73
+ form,
74
+ key => {
75
+ traverseResult.push(key);
76
+ },
77
+ key => {
78
+ traverseResult.push(key);
79
+ }
80
+ );
81
+
82
+ expect(traverseResult).toContain('field');
83
+ expect(traverseResult).toContain('subForm');
84
+ expect(traverseResult).not.toContain('subField');
85
+ });
86
+
87
+ test('formStateToJS', () => {
88
+ const form = getFormState();
89
+ const formStateAsJS = formStateToJS(form);
90
+
91
+ expect(formStateAsJS).toMatchSnapshot();
92
+ });
93
+
94
+ test('formStateToJS complex types', () => {
95
+ const data = {
96
+ a: {
97
+ '1': 1,
98
+ 'string': 'string',
99
+ 'true': true,
100
+ },
101
+ b: [3, 5, 7],
102
+ c: {
103
+ x: 1,
104
+ y: 'string',
105
+ z: true,
106
+ },
107
+ d: {
108
+ '1': true,
109
+ '2': {
110
+ x: 2,
111
+ },
112
+ },
113
+ };
114
+
115
+ const mapForm = new Map<string, FieldState<boolean> | FormState<{ x: FieldState<number> }>>([
116
+ ['1', new FieldState<boolean>(true)],
117
+ [
118
+ '2',
119
+ new FormState({
120
+ x: new FieldState(2),
121
+ }),
122
+ ],
123
+ ]);
124
+
125
+ const form = new FormState({
126
+ a: new FieldState(data.a),
127
+ b: new FieldState(data.b),
128
+ c: new FieldState(data.c),
129
+ d: new FormState(mapForm),
130
+ });
131
+
132
+ expect(formStateToJS(form)).toEqual(data);
133
+ expect(BAD_formStateToJS(form)).toEqual(data);
134
+ });
135
+
136
+ test('setFormStateValues', () => {
137
+ const form = setFormStateValues(getFormState(), {
138
+ field: 2,
139
+ subForm: {
140
+ subSubForm: {
141
+ subSubField: undefined,
142
+ },
143
+ subSubFormMap: { subSubMapField2: 'z' },
144
+ },
145
+ subFormArray2: [3, 4],
146
+ subFormMap: { subMapField: 'infinity' },
147
+ });
148
+
149
+ expect(form.$.field.value).toBe(2);
150
+ expect(form.$.subForm.$.subSubForm.$.subSubField.value).toBe(undefined);
151
+ expect(form.$.subForm.$.subSubForm.$.subSubField2.value).toBe(2);
152
+ expect(form.$.subForm.$.subSubFormMap.$.get('subSubMapField2')!.value).toBe('z');
153
+ expect(form.$.subFormArray2.$.map(item => item.value)).toEqual([3, 4]);
154
+ expect(form.$.subFormMap.$.get('subMapField')!.value).toBe('infinity');
155
+ });
156
+
157
+ test('setFormStateValues array length mismatch', () => {
158
+ const form = new FormState([
159
+ new FormState({
160
+ a: new FieldState(1),
161
+ b: new FormState([
162
+ new FormState({
163
+ c: new FieldState(1),
164
+ d: new FieldState(1),
165
+ }),
166
+ ]),
167
+ }),
168
+ ]);
169
+
170
+ expect(() => setFormStateValues(form, [{ a: 1 }, { a: 2 }])).toThrowError(/Number of elements/);
171
+ expect(() => setFormStateValues(form, [{ a: 1, b: [{ c: 1 }, { c: 1 }] }])).toThrowError(
172
+ /Number of elements/
173
+ );
174
+ });
175
+
176
+ test('setFormStateValues top level array', () => {
177
+ const formState = new FormState([
178
+ new FormState({ a: new FieldState(1), b: new FieldState(2) }),
179
+ new FormState({ a: new FieldState(1), b: new FieldState(2) }),
180
+ ]);
181
+ setFormStateValues(formState, [
182
+ { a: 4, b: 5 },
183
+ { a: 5, b: 6 },
184
+ ]);
185
+
186
+ expect(formState.$[0].$.a.value).toBe(4);
187
+ expect(formState.$[0].$.b.value).toBe(5);
188
+ expect(formState.$[1].$.a.value).toBe(5);
189
+ expect(formState.$[1].$.b.value).toBe(6);
190
+ });
191
+
192
+ test('setFormStateValues top level map', () => {
193
+ const formState = new FormState(
194
+ new Map([
195
+ ['a', new FormState({ a: new FieldState(1), b: new FieldState(2) })],
196
+ ['b', new FormState({ a: new FieldState(1), b: new FieldState(2) })],
197
+ ])
198
+ );
199
+ setFormStateValues(formState, {
200
+ a: { a: 4, b: 5 },
201
+ b: { a: 5, b: 6 },
202
+ });
203
+
204
+ expect(formState.$.get('a')!.$.a.value).toBe(4);
205
+ expect(formState.$.get('a')!.$.b.value).toBe(5);
206
+ expect(formState.$.get('b')!.$.a.value).toBe(5);
207
+ expect(formState.$.get('b')!.$.b.value).toBe(6);
208
+ });
209
+
210
+ test('commitFormState', () => {
211
+ const form = getFormState();
212
+ const oldFieldValue = form.$.field.value;
213
+ const newFieldValue = oldFieldValue + 1;
214
+ const oldSubFieldValue = form.$.subForm.$.subField.value;
215
+ const newSubFieldValue = oldSubFieldValue + 1;
216
+
217
+ form.$.field.value = newFieldValue;
218
+ form.$.subForm.$.subField.value = newSubFieldValue;
219
+ form.reset();
220
+ expect(form.$.field.value).toBe(oldFieldValue);
221
+ expect(form.$.subForm.$.subField.value).toBe(oldSubFieldValue);
222
+
223
+ form.$.field.value = newFieldValue;
224
+ form.$.subForm.$.subField.value = newSubFieldValue;
225
+ commitFormState(form);
226
+ form.reset();
227
+ expect(form.$.field.value).toBe(newFieldValue);
228
+ expect(form.$.subForm.$.subField.value).toBe(newSubFieldValue);
229
+ });
@@ -0,0 +1,55 @@
1
+ import { FormValidators } from '../form-validators';
2
+
3
+ describe('FormValidators', () => {
4
+ describe('website', () => {
5
+ const exec = FormValidators.website();
6
+
7
+ it('should return false if website url is valid', () => {
8
+ for (const url of [
9
+ 'servicetitan.com',
10
+ 'www.servicetitan.com',
11
+ 'http://servicetitan.com',
12
+ 'http://wwww.servicetitan.com',
13
+ 'https://servicetitan.com',
14
+ 'https://wwww.servicetitan.com',
15
+ 'something.servicetitan.com',
16
+ ]) {
17
+ expect(exec(url)).toBeFalsy();
18
+ }
19
+ });
20
+
21
+ it('should return error message if website url is not valid', () => {
22
+ for (const url of [
23
+ 'servicetitan.c',
24
+ 'servicetitan',
25
+ 'htp://servicetitan.com',
26
+ 'http://wwww.servicetitan.com!',
27
+ 'https://servicetitan.com:',
28
+ 'https://wwww.servic:etitan.com',
29
+ ]) {
30
+ expect(exec(url)).toBeTruthy();
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('exactLength', () => {
36
+ const exactLength = 10;
37
+ const exec = FormValidators.exactLength(exactLength);
38
+
39
+ it('should return error message if value length is greater than defined', () => {
40
+ expect(exec('a'.repeat(exactLength + 1))).toBeTruthy();
41
+ });
42
+
43
+ it('should return error message if value length is less than defined', () => {
44
+ expect(exec('a'.repeat(exactLength - 1))).toBeTruthy();
45
+ });
46
+
47
+ it('should return false if value length is equal to defined', () => {
48
+ expect(exec('a'.repeat(exactLength))).toBeFalsy();
49
+ });
50
+
51
+ it('should return false if value is undefined', () => {
52
+ expect(exec(undefined)).toBeFalsy();
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,66 @@
1
+ import { FC, useState } from 'react';
2
+ import { useAsyncLazyDropdownState } from './use-async-lazy-dropdown-state';
3
+ import { AnvilSelectOptionsProps, Form } from '@servicetitan/design-system';
4
+ import { observer } from 'mobx-react';
5
+ import { DropdownOption } from '../dropdown-state';
6
+
7
+ export default {
8
+ title: 'Marketing Form/Async Select Helper',
9
+ component: useAsyncLazyDropdownState,
10
+ parameters: {},
11
+ };
12
+
13
+ const dataFetcher = async (searchString: string) => {
14
+ const options = [
15
+ { value: 1, text: 'Australia' },
16
+ { value: 2, text: 'America' },
17
+ { value: 3, text: 'New Zealand' },
18
+ { value: 4, text: 'Spain' },
19
+ { value: 5, text: 'Turkey' },
20
+ { value: 6, text: 'India' },
21
+ { value: 7, text: 'Russia' },
22
+ { value: 8, text: 'Poland' },
23
+ { value: 9, text: 'Canada' },
24
+ ];
25
+ // simulating remote api call
26
+ const res = await new Promise<DropdownOption<any>[]>(resolve => {
27
+ setTimeout(() => {
28
+ const resultNumber = 3;
29
+ const filteredOptions = options.filter(o =>
30
+ o.text.toLowerCase().includes(searchString.toLowerCase())
31
+ );
32
+ if (filteredOptions.length > resultNumber) {
33
+ filteredOptions.length = resultNumber;
34
+ }
35
+ resolve(filteredOptions);
36
+ }, 1000);
37
+ });
38
+ return res;
39
+ };
40
+
41
+ const BasicExample: FC = observer(() => {
42
+ const [value, setValue] = useState<AnvilSelectOptionsProps>();
43
+ const [state, onOpenChange] = useAsyncLazyDropdownState(value, dataFetcher);
44
+
45
+ return (
46
+ <Form.AnvilSelect
47
+ label="Async Select (For Selects that have to fetch options from a remote server)"
48
+ onChange={setValue}
49
+ small
50
+ scrollHeight="250px"
51
+ trigger={{}}
52
+ search={{
53
+ value: state.search,
54
+ placeholder: 'type something',
55
+ onChange: state.onSearchChange,
56
+ }}
57
+ value={value}
58
+ onOpenChange={onOpenChange}
59
+ loading={state.loading}
60
+ options={state.optionsWithValue}
61
+ multiple
62
+ />
63
+ );
64
+ });
65
+
66
+ export const basic = () => <BasicExample />;
@@ -0,0 +1,77 @@
1
+ import { computed, makeObservable } from 'mobx';
2
+ import { AsyncDataSource } from '@servicetitan/data-query';
3
+ import { AnvilSelectOptionsProps, AnvilSelectPropsStrict } from '@servicetitan/design-system';
4
+ import { DropdownOption, DropdownState } from '../dropdown-state';
5
+
6
+ export type DataFetcher = (searchQuery: string) => Promise<DropdownOption<any>[]>;
7
+
8
+ export class AsyncLazyDropdownState<T extends DropdownOption<any>> extends DropdownState<T> {
9
+ private arrayValue: AnvilSelectOptionsProps[] = [];
10
+
11
+ constructor(dataFetcher: DataFetcher) {
12
+ super({
13
+ dataSource: new AsyncDataSource({
14
+ get: async () => {
15
+ const result: any[] = await dataFetcher(this.search);
16
+
17
+ return {
18
+ data: result,
19
+ total: result.length,
20
+ };
21
+ },
22
+ }),
23
+ lazy: true,
24
+ });
25
+
26
+ makeObservable(this);
27
+ }
28
+
29
+ @computed
30
+ get optionsWithValue() {
31
+ return this.getOptionsWithValue();
32
+ }
33
+
34
+ onChange = (value: AnvilSelectPropsStrict['value']) => {
35
+ const isEmptyValue = value === undefined || (Array.isArray(value) && value.length === 0);
36
+
37
+ this.arrayValue = isEmptyValue ? [] : !Array.isArray(value) ? [value!] : value;
38
+ };
39
+
40
+ setDataFetcher(dataFetcher: DataFetcher) {
41
+ this.setDataSource(
42
+ new AsyncDataSource({
43
+ get: async () => {
44
+ const result: any[] = await dataFetcher(this.search);
45
+
46
+ return {
47
+ data: result,
48
+ total: result.length,
49
+ };
50
+ },
51
+ })
52
+ );
53
+ }
54
+
55
+ private getOptionsWithValue = () => {
56
+ const options = [...this.options];
57
+
58
+ if (!this.search) {
59
+ const values = this.arrayValue;
60
+
61
+ const selectedOptions = options.filter(
62
+ option => !!values.find(value => value.value === option.value)
63
+ );
64
+
65
+ const unselectedOptions = options.filter(
66
+ option => !values.find(value => value.value === option.value)
67
+ );
68
+
69
+ const missingSelectedOptions = values.filter(
70
+ value => !options.some(option => option.value === value.value)
71
+ );
72
+ return [...missingSelectedOptions, ...selectedOptions, ...unselectedOptions];
73
+ }
74
+
75
+ return options;
76
+ };
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from './async-lazy-dropdown-state';
2
+ export * from './use-async-lazy-dropdown-state';
@@ -0,0 +1,38 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { AnvilSelectPropsStrict } from '@servicetitan/design-system';
3
+ import { AsyncLazyDropdownState, DataFetcher } from './async-lazy-dropdown-state';
4
+ import { DropdownOption } from '../dropdown-state';
5
+
6
+ export const useAsyncLazyDropdownState = (
7
+ value: AnvilSelectPropsStrict['value'],
8
+ // try memoising dataFetcher
9
+ dataFetcher: DataFetcher
10
+ ) => {
11
+ const [state] = useState(() => new AsyncLazyDropdownState(dataFetcher));
12
+ useEffect(() => {
13
+ state.onChange(value);
14
+ }, [state, value]);
15
+
16
+ const isInitialMount = useRef(true);
17
+ useEffect(() => {
18
+ if (isInitialMount.current) {
19
+ isInitialMount.current = false;
20
+ } else {
21
+ state.setDataFetcher(dataFetcher);
22
+ }
23
+ }, [state, dataFetcher]);
24
+
25
+ const onOpenChange = useCallback(
26
+ (open: boolean) => {
27
+ if (open) {
28
+ state.setSearch('');
29
+ }
30
+ },
31
+ [state]
32
+ );
33
+
34
+ return [state, onOpenChange] as [
35
+ AsyncLazyDropdownState<DropdownOption<any>>,
36
+ (open: boolean) => void
37
+ ];
38
+ };
@@ -0,0 +1,4 @@
1
+ export interface DateRange {
2
+ from?: Date;
3
+ to?: Date;
4
+ }