oxform-core 0.1.0 → 0.1.1

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 (39) hide show
  1. package/dist/index.cjs +751 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +443 -0
  4. package/dist/index.d.ts +443 -0
  5. package/dist/index.js +747 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +20 -15
  8. package/export/index.ts +0 -7
  9. package/export/schema.ts +0 -1
  10. package/src/field-api.constants.ts +0 -15
  11. package/src/field-api.ts +0 -139
  12. package/src/form-api.ts +0 -84
  13. package/src/form-api.types.ts +0 -148
  14. package/src/form-array-field-api.ts +0 -233
  15. package/src/form-context-api.ts +0 -232
  16. package/src/form-field-api.ts +0 -174
  17. package/src/more-types.ts +0 -178
  18. package/src/tests/array/append.spec.ts +0 -138
  19. package/src/tests/array/insert.spec.ts +0 -182
  20. package/src/tests/array/move.spec.ts +0 -175
  21. package/src/tests/array/prepend.spec.ts +0 -138
  22. package/src/tests/array/remove.spec.ts +0 -174
  23. package/src/tests/array/swap.spec.ts +0 -152
  24. package/src/tests/array/update.spec.ts +0 -148
  25. package/src/tests/field/change.spec.ts +0 -226
  26. package/src/tests/field/reset.spec.ts +0 -617
  27. package/src/tests/field/set-errors.spec.ts +0 -254
  28. package/src/tests/field-api/field-api.spec.ts +0 -341
  29. package/src/tests/form-api/reset.spec.ts +0 -535
  30. package/src/tests/form-api/submit.spec.ts +0 -409
  31. package/src/types.ts +0 -5
  32. package/src/utils/get.ts +0 -5
  33. package/src/utils/testing/sleep.ts +0 -1
  34. package/src/utils/testing/tests.ts +0 -18
  35. package/src/utils/update.ts +0 -6
  36. package/src/utils/validate.ts +0 -8
  37. package/tsconfig.json +0 -3
  38. package/tsdown.config.ts +0 -10
  39. package/vitest.config.ts +0 -3
@@ -1,233 +0,0 @@
1
- import { defaultMeta } from '#field-api.constants';
2
- import type { FieldChangeOptions } from '#form-api.types';
3
- import type { FormContextApi } from '#form-context-api';
4
- import type { FormFieldApi } from '#form-field-api';
5
- import type { DeepKeysOfType, DeepValue, UnwrapOneLevelOfArray } from '#more-types';
6
- import type { StandardSchema } from '#types';
7
- import { get } from '#utils/get';
8
- import { update, type Updater } from '#utils/update';
9
- import { stringToPath } from 'remeda';
10
-
11
- export class FormArrayFieldApi<
12
- Schema extends StandardSchema<any>,
13
- Values extends StandardSchema.InferInput<Schema> = StandardSchema.InferInput<Schema>,
14
- ArrayField extends DeepKeysOfType<Values, any[] | null | undefined> = DeepKeysOfType<
15
- Values,
16
- any[] | null | undefined
17
- >,
18
- > {
19
- private context: FormContextApi<Schema>;
20
- private field: FormFieldApi<Schema>;
21
-
22
- constructor({ field, context }: { field: FormFieldApi<Schema>; context: FormContextApi<Schema> }) {
23
- this.context = context;
24
- this.field = field;
25
- }
26
-
27
- public insert = <Name extends ArrayField>(
28
- name: Name,
29
- index: number,
30
- value: Updater<UnwrapOneLevelOfArray<DeepValue<Values, Name>>>,
31
- options?: FieldChangeOptions,
32
- ) => {
33
- if (index < 0) index = 0;
34
-
35
- this.field.change(
36
- name,
37
- current => {
38
- const array = (current as any[]) ?? [];
39
-
40
- return [
41
- ...array.slice(0, index),
42
- ...(Array.from({ length: index - array.length }, () => undefined) as any[]),
43
- update(value, current as never),
44
- ...array.slice(index),
45
- ] as never;
46
- },
47
- options,
48
- );
49
-
50
- this.context.persisted.setState(current => {
51
- const fields = { ...current.fields };
52
- const value = get(current.values as never, stringToPath(name)) as any[] | undefined;
53
- const length = value?.length;
54
-
55
- if (length === undefined) return current;
56
-
57
- for (let i = index; i < length; i++) {
58
- const moving = current.fields[`${name}.${i}`];
59
- fields[`${name}.${i + 1}`] = moving ?? defaultMeta;
60
- }
61
-
62
- fields[`${name}.${index}`] = defaultMeta;
63
-
64
- return {
65
- ...current,
66
- fields,
67
- };
68
- });
69
- };
70
-
71
- public append = <Name extends ArrayField>(
72
- name: Name,
73
- value: UnwrapOneLevelOfArray<DeepValue<Values, Name>>,
74
- options?: FieldChangeOptions,
75
- ) => {
76
- const current = this.field.get(name) as any[];
77
- return this.insert(name, current?.length ?? 0, value, options);
78
- };
79
-
80
- public prepend = <Name extends ArrayField>(
81
- name: Name,
82
- value: UnwrapOneLevelOfArray<DeepValue<Values, Name>>,
83
- options?: FieldChangeOptions,
84
- ) => {
85
- return this.insert(name, 0, value, options);
86
- };
87
-
88
- public swap = <Name extends ArrayField>(name: Name, from: number, to: number, options?: FieldChangeOptions) => {
89
- const start = from >= 0 ? from : 0;
90
- const end = to >= 0 ? to : 0;
91
-
92
- this.field.change(
93
- name,
94
- current => {
95
- const array = (current as any[]) ?? [];
96
-
97
- if (start === end) return array as never; // no-op
98
-
99
- const a = array[start];
100
- const b = array[end];
101
-
102
- return [...array.slice(0, start), b, ...array.slice(start + 1, end), a, ...array.slice(end + 1)] as never;
103
- },
104
- options,
105
- );
106
-
107
- this.context.persisted.setState(current => {
108
- const fields = { ...current.fields };
109
-
110
- fields[`${name}.${start}`] = current.fields[`${name}.${end}`] ?? defaultMeta;
111
- fields[`${name}.${end}`] = current.fields[`${name}.${start}`] ?? defaultMeta;
112
-
113
- return {
114
- ...current,
115
- fields,
116
- };
117
- });
118
- };
119
-
120
- public move<Name extends ArrayField>(name: Name, _from: number, _to: number, options?: FieldChangeOptions) {
121
- const from = Math.max(_from, 0);
122
- const to = Math.max(_to, 0);
123
- const backwards = from > to;
124
-
125
- this.field.change(
126
- name,
127
- current => {
128
- const array: any[] = current ? [...(current as any[])] : [];
129
-
130
- if (from === to) return array as never;
131
-
132
- const moved = array[from];
133
- return array.toSpliced(backwards ? to : to + 1, 0, moved).toSpliced(backwards ? from + 1 : from, 1) as never;
134
- },
135
- options,
136
- );
137
-
138
- this.context.persisted.setState(current => {
139
- const fields = { ...current.fields };
140
- const value = get(current.values as never, stringToPath(name)) as any[] | undefined;
141
- const length = value?.length;
142
-
143
- const start = Math.min(from, to);
144
- const end = Math.max(from, to);
145
-
146
- if (length === undefined) return current;
147
-
148
- if (!backwards) {
149
- fields[`${name}.${to}`] = current.fields[`${name}.${from}`] ?? defaultMeta;
150
- }
151
-
152
- for (let i = backwards ? start + 1 : start; i < end; i++) {
153
- const shift = backwards ? -1 : 1;
154
- const moving = current.fields[`${name}.${i + shift}`];
155
- fields[`${name}.${i}`] = moving ?? defaultMeta;
156
- }
157
-
158
- return {
159
- ...current,
160
- fields,
161
- };
162
- });
163
- }
164
-
165
- public update = <Name extends ArrayField>(
166
- name: Name,
167
- index: number,
168
- value: Updater<UnwrapOneLevelOfArray<DeepValue<Values, Name>>>,
169
- options?: FieldChangeOptions,
170
- ) => {
171
- this.field.change(
172
- name,
173
- current => {
174
- const array = (current as any[]) ?? [];
175
- const position = Math.max(Math.min(index, array.length - 1), 0);
176
-
177
- return [...array.slice(0, position), update(value, current as never), ...array.slice(position + 1)] as never;
178
- },
179
- options,
180
- );
181
-
182
- this.context.resetFieldMeta(`${name}.${index}`);
183
- this.context.setFieldMeta(`${name}.${index}`, {
184
- dirty: options?.should?.dirty !== false,
185
- touched: options?.should?.touch !== false,
186
- });
187
- };
188
-
189
- public remove<Name extends ArrayField>(name: Name, index: number, options?: FieldChangeOptions) {
190
- let position = index;
191
-
192
- this.field.change(
193
- name,
194
- current => {
195
- const array = (current as any[]) ?? [];
196
- position = Math.max(Math.min(index, array.length - 1), 0);
197
-
198
- return [...array.slice(0, position), ...array.slice(position + 1)] as never;
199
- },
200
- { ...options, should: { ...options?.should, validate: false } },
201
- );
202
-
203
- this.context.persisted.setState(current => {
204
- const fields = { ...current.fields };
205
- const value = get(current.values as never, stringToPath(name)) as any[] | undefined;
206
- const length = value?.length ?? 0;
207
-
208
- for (let i = position; i < length; i++) {
209
- const moving = current.fields[`${name}.${i + 1}`];
210
- fields[`${name}.${i}`] = moving ?? defaultMeta;
211
- }
212
-
213
- delete fields[`${name}.${length}`];
214
-
215
- return {
216
- ...current,
217
- fields,
218
- };
219
- });
220
-
221
- const shouldValidate = options?.should?.validate !== false;
222
- if (shouldValidate) void this.context.validate(name, { type: 'change' });
223
- }
224
-
225
- public replace<Name extends ArrayField>(
226
- name: Name,
227
- value: Updater<DeepValue<Values, Name>>,
228
- options?: FieldChangeOptions,
229
- ) {
230
- this.context.resetFieldMeta(name);
231
- this.field.change(name, value as never, options);
232
- }
233
- }
@@ -1,232 +0,0 @@
1
- import { defaultMeta, defaultStatus } from '#field-api.constants';
2
- import type {
3
- FieldMeta,
4
- FormBaseStore,
5
- FormIssue,
6
- FormOptions,
7
- FormStore,
8
- PersistedFieldMeta,
9
- PersistedFormStatus,
10
- ValidateOptions,
11
- } from '#form-api.types';
12
- import type { DeepKeys } from '#more-types';
13
- import type { StandardSchema } from '#types';
14
- import { get } from '#utils/get';
15
- import { validate } from '#utils/validate';
16
- import { Derived, Store } from '@tanstack/store';
17
- import { isDeepEqual, isFunction, mergeDeep, stringToPath } from 'remeda';
18
-
19
- export class FormContextApi<
20
- Schema extends StandardSchema,
21
- Values extends StandardSchema.InferInput<Schema> = StandardSchema.InferInput<Schema>,
22
- Field extends DeepKeys<Values> = DeepKeys<Values>,
23
- > {
24
- public options!: FormOptions<Schema>;
25
- public persisted: Store<FormBaseStore<Schema>>;
26
- public store!: Derived<FormStore<Schema>>;
27
-
28
- constructor(options: FormOptions<Schema>) {
29
- const values = mergeDeep(options.defaultValues as never, options.values ?? {}) as Values;
30
-
31
- this.options = options;
32
-
33
- this.persisted = new Store<FormBaseStore<Schema>>({
34
- values,
35
- fields: {},
36
- refs: {},
37
- status: defaultStatus,
38
- errors: {},
39
- });
40
-
41
- this.store = new Derived<FormStore<Schema>>({
42
- deps: [this.persisted],
43
- fn: ({ currDepVals }) => {
44
- const persisted = currDepVals[0] as FormBaseStore<Schema>;
45
-
46
- const invalid = Object.values(persisted.errors).some(issues => issues.length > 0);
47
- const dirty = Object.values(persisted.fields).some(meta => meta.dirty);
48
- const fields = Object.fromEntries(
49
- Object.entries(persisted.fields).map(([key, meta]) => {
50
- return [key, this.buildFieldMeta(key, meta, persisted.values as Values, persisted.errors)];
51
- }),
52
- );
53
-
54
- return {
55
- ...persisted,
56
- fields,
57
- status: {
58
- ...persisted.status,
59
- submitted: persisted.status.submits > 0,
60
- valid: !invalid,
61
- dirty: persisted.status.dirty || dirty,
62
- },
63
- };
64
- },
65
- });
66
- }
67
-
68
- public get status() {
69
- return this.store.state.status;
70
- }
71
-
72
- public get values() {
73
- return this.store.state.values;
74
- }
75
-
76
- private get validator() {
77
- const store = this.store.state;
78
- const validate = this.options.validate;
79
-
80
- return {
81
- change: isFunction(validate?.change) ? validate.change(store) : validate?.change,
82
- submit: isFunction(validate?.submit) ? validate.submit(store) : (validate?.submit ?? this.options.schema),
83
- blur: isFunction(validate?.blur) ? validate.blur(store) : validate?.blur,
84
- focus: isFunction(validate?.focus) ? validate.focus(store) : validate?.focus,
85
- };
86
- }
87
-
88
- public buildFieldMeta = (
89
- fieldName: string,
90
- persistedMeta: PersistedFieldMeta | undefined,
91
- values: Values,
92
- errors: Record<string, FormIssue[]>,
93
- ): FieldMeta => {
94
- const path = stringToPath(fieldName);
95
- const value = get(values as never, path);
96
- const defaultValue = get(this.options.defaultValues, path);
97
- const invalid = errors[fieldName]?.length > 0;
98
- const baseMeta = persistedMeta ?? defaultMeta;
99
-
100
- return {
101
- ...baseMeta,
102
- default: isDeepEqual(value, defaultValue),
103
- pristine: !baseMeta.dirty,
104
- valid: !invalid,
105
- } satisfies FieldMeta;
106
- };
107
-
108
- public setFieldMeta = (name: string, meta: Partial<PersistedFieldMeta>) => {
109
- this.persisted.setState(current => {
110
- return {
111
- ...current,
112
- fields: {
113
- ...current.fields,
114
- [name]: {
115
- ...defaultMeta,
116
- ...this.persisted.state.fields[name],
117
- ...meta,
118
- },
119
- },
120
- };
121
- });
122
- };
123
-
124
- public resetFieldMeta = (name: string) => {
125
- this.persisted.setState(current => {
126
- const fields = { ...current.fields };
127
- const all = Object.keys(current.fields);
128
- const affected = all.filter(key => key.startsWith(name));
129
-
130
- for (const key of affected) {
131
- delete fields[key];
132
- }
133
-
134
- return {
135
- ...current,
136
- fields,
137
- };
138
- });
139
- };
140
-
141
- public recomputeFieldMeta = (name: string) => {
142
- this.persisted.setState(current => {
143
- const related: string[] = this.options.related?.[name as never] ?? [];
144
- const all = Object.keys(current.fields);
145
- const affected = all.filter(key => key.startsWith(name) || related.includes(key));
146
- const updated = affected.reduce(
147
- (acc, key) => {
148
- return {
149
- ...acc,
150
- [key]: this.buildFieldMeta(key, current.fields[key], current.values as Values, current.errors),
151
- };
152
- },
153
- {} as Record<string, FieldMeta>,
154
- );
155
-
156
- return {
157
- ...current,
158
- fields: {
159
- ...current.fields,
160
- ...updated,
161
- },
162
- };
163
- });
164
- };
165
-
166
- public setStatus = (status: Partial<PersistedFormStatus>) => {
167
- this.persisted.setState(current => {
168
- return {
169
- ...current,
170
- status: {
171
- ...current.status,
172
- ...status,
173
- },
174
- };
175
- });
176
- };
177
-
178
- public validate = async (field?: Field | Field[], options?: ValidateOptions) => {
179
- const validator = options?.type ? this.validator[options.type] : this.options.schema;
180
-
181
- if (!validator) return [];
182
-
183
- this.setStatus({ validating: true });
184
-
185
- const { issues: allIssues } = await validate(validator, this.store.state.values);
186
-
187
- if (!allIssues) {
188
- this.persisted.setState(current => {
189
- return { ...current, errors: {}, status: { ...current.status, validating: false } };
190
- });
191
-
192
- return [];
193
- }
194
-
195
- const fields = field ? (Array.isArray(field) ? field : [field]) : undefined;
196
- const related: string[] = fields?.flatMap(field => this.options.related?.[field as never] ?? []) ?? [];
197
- const affected = [...(fields ?? []), ...related];
198
-
199
- const issues = allIssues.filter(issue => {
200
- const path = issue.path?.join('.') ?? 'root';
201
- return !fields || affected.some(key => path.startsWith(key));
202
- });
203
-
204
- const errors = issues.reduce((acc, issue) => {
205
- const path = issue.path?.join('.') ?? 'root';
206
- return {
207
- ...acc,
208
- [path]: [...(acc[path] ?? []), issue],
209
- };
210
- }, {} as any);
211
-
212
- this.persisted.setState(current => {
213
- const existing = { ...current.errors };
214
-
215
- for (const key of affected) {
216
- delete existing[key];
217
- }
218
-
219
- return {
220
- ...current,
221
- errors: {
222
- ...(fields ? existing : {}), // when validating a specific set of fields, keep the existing errors
223
- ...errors,
224
- },
225
- };
226
- });
227
-
228
- this.setStatus({ validating: false });
229
-
230
- return issues;
231
- };
232
- }
@@ -1,174 +0,0 @@
1
- import { defaultMeta } from '#field-api.constants';
2
- import type { FieldChangeOptions, FormIssue, FormResetFieldOptions, FormSetErrorsOptions } from '#form-api.types';
3
- import type { FormContextApi } from '#form-context-api';
4
- import type { DeepKeys, DeepValue } from '#more-types';
5
- import type { StandardSchema } from '#types';
6
- import { get } from '#utils/get';
7
- import { update, type Updater } from '#utils/update';
8
- import { batch } from '@tanstack/store';
9
- import { setPath, stringToPath } from 'remeda';
10
-
11
- export class FormFieldApi<
12
- Schema extends StandardSchema,
13
- Values extends StandardSchema.InferInput<Schema> = StandardSchema.InferInput<Schema>,
14
- Field extends DeepKeys<Values> = DeepKeys<Values>,
15
- > {
16
- constructor(private context: FormContextApi<Schema>) {}
17
-
18
- /**
19
- * Changes the value of a specific field with optional control over side effects.
20
- * @param name - The name of the field to change
21
- * @param value - The new value to set for the field
22
- * @param options - Optional configuration for controlling validation, dirty state, and touched state
23
- */
24
- public change = <Name extends Field>(
25
- name: Name,
26
- updater: Updater<DeepValue<Values, Name>>,
27
- options?: FieldChangeOptions,
28
- ) => {
29
- const shouldDirty = options?.should?.dirty !== false;
30
- const shouldTouch = options?.should?.touch !== false;
31
- const shouldValidate = options?.should?.validate !== false;
32
-
33
- this.context.persisted.setState(current => {
34
- const value = get(current.values as never, stringToPath(name)) as DeepValue<Values, Name>;
35
-
36
- const values = setPath(
37
- current.values as never,
38
- stringToPath(name) as never,
39
- update(updater, value) as never,
40
- ) as Values;
41
-
42
- return {
43
- ...current,
44
- values,
45
- };
46
- });
47
-
48
- if (shouldValidate) void this.context.validate(name, { type: 'change' });
49
-
50
- batch(() => {
51
- if (shouldDirty) this.context.setFieldMeta(name, { dirty: true });
52
- if (shouldTouch) this.context.setFieldMeta(name, { touched: true });
53
- });
54
- };
55
-
56
- public focus = <Name extends Field>(name: Name) => {
57
- const ref = this.context.store.state.refs[name as never];
58
-
59
- if (ref) ref.focus();
60
-
61
- this.context.setFieldMeta(name as never, { touched: true });
62
- void this.context.validate(name as never, { type: 'focus' });
63
- };
64
-
65
- public blur = <Name extends Field>(name: Name) => {
66
- const ref = this.context.store.state.refs[name as never];
67
-
68
- if (ref) ref.blur();
69
-
70
- this.context.setFieldMeta(name as never, { blurred: true });
71
- void this.context.validate(name as never, { type: 'blur' });
72
- };
73
-
74
- public get = <Name extends Field>(name: Name) => {
75
- return get(this.context.store.state.values as never, stringToPath(name)) as DeepValue<Values, Name>;
76
- };
77
-
78
- public meta = <Name extends Field>(name: Name) => {
79
- const meta = this.context.store.state.fields[name];
80
-
81
- if (meta) return meta;
82
-
83
- const updated = this.context.buildFieldMeta(
84
- name,
85
- undefined,
86
- this.context.store.state.values as Values,
87
- this.context.store.state.errors,
88
- );
89
-
90
- this.context.setFieldMeta(name, updated);
91
-
92
- return updated;
93
- };
94
-
95
- public register = <Name extends Field>(name: Name) => {
96
- return (element: HTMLElement | null) => {
97
- if (!element) return;
98
-
99
- this.context.persisted.setState(current => {
100
- return {
101
- ...current,
102
- refs: {
103
- ...current.refs,
104
- [name]: element,
105
- },
106
- };
107
- });
108
- };
109
- };
110
-
111
- public errors = <Name extends Field>(name: Name) => {
112
- return this.context.store.state.errors[name] ?? [];
113
- };
114
-
115
- public setErrors = <Name extends Field>(name: Name, errors: FormIssue[], options?: FormSetErrorsOptions) => {
116
- this.context.persisted.setState(current => {
117
- const existing = current.errors[name] ?? [];
118
- let updated: FormIssue[];
119
-
120
- switch (options?.mode) {
121
- case 'append':
122
- updated = [...existing, ...errors];
123
- break;
124
- case 'keep':
125
- updated = existing.length > 0 ? existing : errors;
126
- break;
127
- case 'replace':
128
- default:
129
- updated = errors;
130
- break;
131
- }
132
-
133
- return {
134
- ...current,
135
- errors: {
136
- ...current.errors,
137
- [name]: updated,
138
- },
139
- };
140
- });
141
- };
142
-
143
- public reset = <Name extends Field>(name: Name, options?: FormResetFieldOptions<DeepValue<Values, Name>>) => {
144
- const path = stringToPath(name as never);
145
- const defaultValue = get(this.context.options.defaultValues, path) as DeepValue<Values, Name>;
146
- const value = options?.value ?? defaultValue;
147
-
148
- this.context.persisted.setState(current => {
149
- const values = setPath(current.values as never, path as never, value as never);
150
- const fields = { ...current.fields };
151
- const refs = { ...current.refs };
152
- const errors = { ...current.errors };
153
-
154
- if (options?.meta) {
155
- const currentMeta = options?.keep?.meta ? (fields[name as string] ?? defaultMeta) : defaultMeta;
156
- fields[name as string] = {
157
- ...currentMeta,
158
- ...options.meta,
159
- };
160
- } else if (!options?.keep?.meta) delete fields[name as string];
161
-
162
- if (!options?.keep?.refs) delete refs[name as string];
163
- if (!options?.keep?.errors) delete errors[name as string];
164
-
165
- return {
166
- ...current,
167
- values,
168
- fields,
169
- refs,
170
- errors,
171
- };
172
- });
173
- };
174
- }