Skip to content

Commit 6633033

Browse files
authored
feat: enhance Vue form factory with new components and context management (#23)
- Added new default components for Vue forms, including DefaultFormField, DefaultFormSectionContainer, DefaultInputAutocomplete, and others for improved form rendering. - Introduced ScheptaFormContext to provide form adapter to child components, enabling better state management. - Updated VueFormAdapter to support nested value handling and improved validation logic. - Enhanced the form factory setup to include custom components and middleware support. - Updated pnpm-lock.yaml to reflect new dependencies and versions. - Added new images for visual representation of complex forms.
1 parent 3372774 commit 6633033

53 files changed

Lines changed: 2510 additions & 309 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/adapters/vue/src/form-adapter.ts

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
11
/**
22
* Vue Form Adapter
3-
*
3+
*
44
* Implements FormAdapter using Vue reactive state
55
*/
66

7-
import { ref, reactive, watch, type Ref } from 'vue';
7+
import { ref, reactive, watch } from 'vue';
88
import type { FormAdapter, FieldOptions, ReactiveState } from '@schepta/core';
99
import { VueReactiveState } from './reactive-state';
1010

11+
function getNestedValue(obj: Record<string, any>, path: string): any {
12+
const parts = path.split('.');
13+
let value: any = obj;
14+
for (const part of parts) {
15+
if (value === undefined || value === null) return undefined;
16+
value = value[part];
17+
}
18+
return value;
19+
}
20+
21+
function setNestedValue(obj: Record<string, any>, path: string, value: any): void {
22+
const parts = path.split('.');
23+
let current: any = obj;
24+
25+
for (let i = 0; i < parts.length - 1; i++) {
26+
const part = parts[i];
27+
if (current[part] === undefined || current[part] === null) {
28+
current[part] = {};
29+
} else if (typeof current[part] !== 'object') {
30+
current[part] = {};
31+
} else {
32+
current[part] = { ...current[part] };
33+
}
34+
current = current[part];
35+
}
36+
current[parts[parts.length - 1]] = value;
37+
}
38+
1139
/**
1240
* Vue form adapter implementation
1341
*/
@@ -26,58 +54,72 @@ export class VueFormAdapter implements FormAdapter {
2654
return { ...this.values };
2755
}
2856

57+
/**
58+
* Get the reactive values object (for framework integration).
59+
* Mutations to this object are reflected in the adapter state.
60+
*/
61+
getState(): Record<string, any> {
62+
return this.values;
63+
}
64+
2965
getValue(field: string): any {
30-
return this.values[field];
66+
return getNestedValue(this.values, field);
3167
}
3268

3369
setValue(field: string, value: any): void {
34-
this.values[field] = value;
35-
// Validate on set
36-
this.validateField(field);
70+
setNestedValue(this.values, field, value);
71+
this.validateField(field, value);
3772
}
3873

3974
watch(field?: string): ReactiveState<any> {
4075
if (field) {
41-
const fieldRef = ref(this.values[field]);
42-
watch(() => this.values[field], (newValue) => {
43-
fieldRef.value = newValue;
44-
});
76+
const fieldRef = ref(this.getValue(field));
77+
watch(
78+
() => this.getValue(field),
79+
(newValue) => {
80+
fieldRef.value = newValue;
81+
},
82+
{ deep: true }
83+
);
4584
return new VueReactiveState(fieldRef);
4685
} else {
4786
const allValuesRef = ref(this.getValues());
48-
watch(() => this.values, (newValues) => {
49-
allValuesRef.value = { ...newValues };
50-
}, { deep: true });
87+
watch(
88+
() => this.values,
89+
() => {
90+
allValuesRef.value = { ...this.values };
91+
},
92+
{ deep: true }
93+
);
5194
return new VueReactiveState(allValuesRef);
5295
}
5396
}
5497

5598
reset(values?: Record<string, any>): void {
5699
if (values) {
100+
Object.keys(this.values).forEach((key) => delete this.values[key]);
57101
Object.assign(this.values, values);
58102
} else {
59-
Object.keys(this.values).forEach(key => {
60-
delete this.values[key];
61-
});
103+
Object.keys(this.values).forEach((key) => delete this.values[key]);
62104
}
63-
Object.keys(this.errors).forEach(key => {
64-
delete this.errors[key];
65-
});
105+
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
66106
}
67107

68108
register(field: string, options?: FieldOptions): void {
69109
if (options?.validate) {
70110
this.validators.set(field, options.validate);
71111
}
72-
if (options?.defaultValue !== undefined) {
73-
this.values[field] = options.defaultValue;
112+
if (options?.defaultValue !== undefined && this.getValue(field) === undefined) {
113+
this.setValue(field, options.defaultValue);
74114
}
75115
}
76116

77117
unregister(field: string): void {
78-
delete this.values[field];
79-
delete this.errors[field];
80118
this.validators.delete(field);
119+
if (field.indexOf('.') === -1) {
120+
delete this.values[field];
121+
}
122+
delete this.errors[field];
81123
}
82124

83125
getErrors(): Record<string, any> {
@@ -96,9 +138,7 @@ export class VueFormAdapter implements FormAdapter {
96138
if (field) {
97139
delete this.errors[field];
98140
} else {
99-
Object.keys(this.errors).forEach(key => {
100-
delete this.errors[key];
101-
});
141+
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
102142
}
103143
}
104144

@@ -108,16 +148,32 @@ export class VueFormAdapter implements FormAdapter {
108148

109149
handleSubmit(onSubmit: (values: Record<string, any>) => void | Promise<void>): () => void {
110150
return () => {
111-
if (this.isValid()) {
112-
onSubmit(this.getValues());
151+
let hasErrors = false;
152+
const newErrors: Record<string, any> = {};
153+
154+
this.validators.forEach((validator, field) => {
155+
const value = this.getValue(field);
156+
const result = validator(value);
157+
if (result !== true) {
158+
hasErrors = true;
159+
newErrors[field] = typeof result === 'string' ? result : 'Validation failed';
160+
}
161+
});
162+
163+
if (hasErrors) {
164+
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
165+
Object.assign(this.errors, newErrors);
166+
return;
113167
}
168+
169+
onSubmit(this.getValues());
114170
};
115171
}
116172

117-
private validateField(field: string): void {
173+
private validateField(field: string, value: any): void {
118174
const validator = this.validators.get(field);
119175
if (validator) {
120-
const result = validator(this.values[field]);
176+
const result = validator(value);
121177
if (result === true) {
122178
delete this.errors[field];
123179
} else if (typeof result === 'string') {
@@ -135,4 +191,3 @@ export class VueFormAdapter implements FormAdapter {
135191
export function createVueFormAdapter(initialValues?: Record<string, any>): VueFormAdapter {
136192
return new VueFormAdapter(initialValues);
137193
}
138-

packages/adapters/vue/src/provider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { defaultDebugConfig } from '@schepta/core';
1616
*/
1717
export interface ScheptaProviderProps {
1818
components?: Record<string, ComponentSpec>;
19+
customComponents?: Record<string, ComponentSpec>;
1920
renderers?: Partial<Record<ComponentType, RendererFn>>;
2021
middlewares?: MiddlewareFn[];
2122
debug?: DebugConfig;
@@ -28,6 +29,7 @@ export interface ScheptaProviderProps {
2829
*/
2930
export interface ScheptaContextType {
3031
components: Record<string, ComponentSpec>;
32+
customComponents: Record<string, ComponentSpec>;
3133
renderers: Record<ComponentType, RendererFn>;
3234
middlewares: MiddlewareFn[];
3335
debug: DebugConfig;
@@ -52,6 +54,10 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {
5254
type: Object,
5355
default: () => props.components || {},
5456
},
57+
customComponents: {
58+
type: Object,
59+
default: () => props.customComponents || {},
60+
},
5561
renderers: {
5662
type: Object,
5763
default: () => props.renderers || {},
@@ -87,6 +93,7 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {
8793

8894
return {
8995
components: { ...parentContext.components, ...componentProps.components },
96+
customComponents: { ...parentContext.customComponents, ...(componentProps.customComponents || {}) },
9097
renderers: mergedRenderers,
9198
middlewares: [...parentContext.middlewares, ...localMiddlewares],
9299
debug: { ...parentContext.debug, ...componentProps.debug },
@@ -100,6 +107,7 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {
100107

101108
return {
102109
components: componentProps.components || {},
110+
customComponents: componentProps.customComponents || {},
103111
renderers: componentProps.renderers,
104112
middlewares: localMiddlewares,
105113
debug: mergedDebug,

packages/adapters/vue/src/runtime-adapter.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
*/
66

77
import { h, Fragment, VNode } from 'vue';
8-
import type { RuntimeAdapter, ComponentSpec, RenderResult } from '@schepta/core';
8+
import type { RuntimeAdapter, ComponentSpec, RenderResult, RendererSpec } from '@schepta/core';
99

1010
/**
1111
* Vue runtime adapter implementation
1212
*/
1313
export class VueRuntimeAdapter implements RuntimeAdapter {
14-
create(spec: ComponentSpec, props: Record<string, any>): RenderResult {
14+
create(spec: ComponentSpec | RendererSpec, props: Record<string, any>): RenderResult {
15+
if (!spec.component) {
16+
throw new Error(`Component ${(spec as any).id} is not a function`);
17+
}
1518
const component = spec.component(props, this) as any;
1619

1720
// Extract children from props if present (Vue passes children as third argument to h())
Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,59 @@
11
/**
22
* Default Form Container Component for Vue
3-
*
3+
*
44
* Built-in form container that wraps children in a <form> tag
55
* and renders a submit button. Can be overridden via createComponentSpec.
66
*/
77

88
import { defineComponent, h, type PropType } from 'vue';
9-
import { DefaultSubmitButton, type SubmitButtonComponentType } from './DefaultSubmitButton';
9+
import { DefaultSubmitButton } from './DefaultSubmitButton';
10+
import { useScheptaFormAdapter } from '../context/schepta-form-context';
1011

1112
/**
1213
* Props for FormContainer component.
1314
* Use this type when creating a custom FormContainer.
1415
*/
1516
export interface FormContainerProps {
1617
/** Submit handler - when provided, renders a submit button */
17-
onSubmit?: () => void;
18+
onSubmit?: (values: Record<string, any>) => void | Promise<void>;
1819
/** External context passed from FormFactory */
1920
externalContext?: Record<string, any>;
20-
/**
21-
* Custom SubmitButton component - resolved by FormFactory from registry.
22-
* If not provided, uses DefaultSubmitButton.
23-
*/
24-
SubmitButtonComponent?: SubmitButtonComponentType;
2521
}
2622

2723
/**
2824
* Default form container component for Vue.
29-
*
25+
*
3026
* Renders children inside a <form> tag with an optional submit button.
31-
*
32-
* - When `onSubmit` is provided: renders submit button inside the form
33-
* - When `onSubmit` is NOT provided: no submit button (for external submit via formRef)
27+
* Uses ScheptaFormAdapter for form submission.
3428
*/
3529
export const DefaultFormContainer = defineComponent({
3630
name: 'DefaultFormContainer',
3731
props: {
3832
onSubmit: {
39-
type: Function as PropType<() => void>,
33+
type: Function as PropType<(values: Record<string, any>) => void | Promise<void>>,
4034
required: false,
4135
},
4236
},
4337
setup(props, { slots }) {
38+
const adapter = useScheptaFormAdapter();
4439
return () => {
45-
return h('form', {
46-
'data-test-id': 'FormContainer',
47-
onSubmit: (e: Event) => {
48-
e.preventDefault();
49-
props.onSubmit?.();
40+
const handleSubmit = (e: Event) => {
41+
e.preventDefault();
42+
if (props.onSubmit) {
43+
adapter.handleSubmit(props.onSubmit)();
5044
}
51-
}, [
52-
slots.default?.(),
53-
props.onSubmit && h(DefaultSubmitButton, { onSubmit: props.onSubmit })
54-
]);
45+
};
46+
return h(
47+
'form',
48+
{
49+
'data-test-id': 'FormContainer',
50+
onSubmit: handleSubmit,
51+
},
52+
[
53+
slots.default?.(),
54+
props.onSubmit && h(DefaultSubmitButton),
55+
]
56+
);
5557
};
56-
}
58+
},
5759
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Default FormField Component for Vue
3+
*
4+
* Wrapper (grid cell) for a single form field.
5+
*/
6+
7+
import { defineComponent, h, type PropType } from 'vue';
8+
9+
export const DefaultFormField = defineComponent({
10+
name: 'DefaultFormField',
11+
props: {
12+
'data-test-id': String,
13+
'x-component-props': Object as PropType<Record<string, any>>,
14+
'x-ui': Object as PropType<Record<string, any>>,
15+
},
16+
setup(props, { slots }) {
17+
return () => {
18+
const xProps = props['x-component-props'] || {};
19+
const { style, ...restXProps } = xProps;
20+
return h(
21+
'div',
22+
{
23+
style,
24+
'data-test-id': props['data-test-id'],
25+
...restXProps,
26+
},
27+
slots.default?.()
28+
);
29+
};
30+
},
31+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Default FormSectionContainer Component for Vue
3+
*
4+
* Container for a form section (title + groups).
5+
*/
6+
7+
import { defineComponent, h } from 'vue';
8+
9+
export const DefaultFormSectionContainer = defineComponent({
10+
name: 'DefaultFormSectionContainer',
11+
setup(props, { slots }) {
12+
return () =>
13+
h(
14+
'div',
15+
{
16+
style: { marginBottom: '24px' },
17+
},
18+
slots.default?.()
19+
);
20+
},
21+
});

0 commit comments

Comments
 (0)