Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 86 additions & 31 deletions packages/adapters/vue/src/form-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
/**
* Vue Form Adapter
*
*
* Implements FormAdapter using Vue reactive state
*/

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

function getNestedValue(obj: Record<string, any>, path: string): any {
const parts = path.split('.');
let value: any = obj;
for (const part of parts) {
if (value === undefined || value === null) return undefined;
value = value[part];
}
return value;
}

function setNestedValue(obj: Record<string, any>, path: string, value: any): void {
const parts = path.split('.');
let current: any = obj;

for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current[part] === undefined || current[part] === null) {
current[part] = {};
} else if (typeof current[part] !== 'object') {
current[part] = {};
} else {
current[part] = { ...current[part] };
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
}

/**
* Vue form adapter implementation
*/
Expand All @@ -26,58 +54,72 @@ export class VueFormAdapter implements FormAdapter {
return { ...this.values };
}

/**
* Get the reactive values object (for framework integration).
* Mutations to this object are reflected in the adapter state.
*/
getState(): Record<string, any> {
return this.values;
}

getValue(field: string): any {
return this.values[field];
return getNestedValue(this.values, field);
}

setValue(field: string, value: any): void {
this.values[field] = value;
// Validate on set
this.validateField(field);
setNestedValue(this.values, field, value);
this.validateField(field, value);
}

watch(field?: string): ReactiveState<any> {
if (field) {
const fieldRef = ref(this.values[field]);
watch(() => this.values[field], (newValue) => {
fieldRef.value = newValue;
});
const fieldRef = ref(this.getValue(field));
watch(
() => this.getValue(field),
(newValue) => {
fieldRef.value = newValue;
},
{ deep: true }
);
return new VueReactiveState(fieldRef);
} else {
const allValuesRef = ref(this.getValues());
watch(() => this.values, (newValues) => {
allValuesRef.value = { ...newValues };
}, { deep: true });
watch(
() => this.values,
() => {
allValuesRef.value = { ...this.values };
},
{ deep: true }
);
return new VueReactiveState(allValuesRef);
}
}

reset(values?: Record<string, any>): void {
if (values) {
Object.keys(this.values).forEach((key) => delete this.values[key]);
Object.assign(this.values, values);
} else {
Object.keys(this.values).forEach(key => {
delete this.values[key];
});
Object.keys(this.values).forEach((key) => delete this.values[key]);
}
Object.keys(this.errors).forEach(key => {
delete this.errors[key];
});
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
}

register(field: string, options?: FieldOptions): void {
if (options?.validate) {
this.validators.set(field, options.validate);
}
if (options?.defaultValue !== undefined) {
this.values[field] = options.defaultValue;
if (options?.defaultValue !== undefined && this.getValue(field) === undefined) {
this.setValue(field, options.defaultValue);
}
}

unregister(field: string): void {
delete this.values[field];
delete this.errors[field];
this.validators.delete(field);
if (field.indexOf('.') === -1) {
delete this.values[field];
}
delete this.errors[field];
}

getErrors(): Record<string, any> {
Expand All @@ -96,9 +138,7 @@ export class VueFormAdapter implements FormAdapter {
if (field) {
delete this.errors[field];
} else {
Object.keys(this.errors).forEach(key => {
delete this.errors[key];
});
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
}
}

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

handleSubmit(onSubmit: (values: Record<string, any>) => void | Promise<void>): () => void {
return () => {
if (this.isValid()) {
onSubmit(this.getValues());
let hasErrors = false;
const newErrors: Record<string, any> = {};

this.validators.forEach((validator, field) => {
const value = this.getValue(field);
const result = validator(value);
if (result !== true) {
hasErrors = true;
newErrors[field] = typeof result === 'string' ? result : 'Validation failed';
}
});

if (hasErrors) {
Object.keys(this.errors).forEach((key) => delete this.errors[key]);
Object.assign(this.errors, newErrors);
return;
}

onSubmit(this.getValues());
};
}

private validateField(field: string): void {
private validateField(field: string, value: any): void {
const validator = this.validators.get(field);
if (validator) {
const result = validator(this.values[field]);
const result = validator(value);
if (result === true) {
delete this.errors[field];
} else if (typeof result === 'string') {
Expand All @@ -135,4 +191,3 @@ export class VueFormAdapter implements FormAdapter {
export function createVueFormAdapter(initialValues?: Record<string, any>): VueFormAdapter {
return new VueFormAdapter(initialValues);
}

8 changes: 8 additions & 0 deletions packages/adapters/vue/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { defaultDebugConfig } from '@schepta/core';
*/
export interface ScheptaProviderProps {
components?: Record<string, ComponentSpec>;
customComponents?: Record<string, ComponentSpec>;
renderers?: Partial<Record<ComponentType, RendererFn>>;
middlewares?: MiddlewareFn[];
debug?: DebugConfig;
Expand All @@ -28,6 +29,7 @@ export interface ScheptaProviderProps {
*/
export interface ScheptaContextType {
components: Record<string, ComponentSpec>;
customComponents: Record<string, ComponentSpec>;
renderers: Record<ComponentType, RendererFn>;
middlewares: MiddlewareFn[];
debug: DebugConfig;
Expand All @@ -52,6 +54,10 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {
type: Object,
default: () => props.components || {},
},
customComponents: {
type: Object,
default: () => props.customComponents || {},
},
renderers: {
type: Object,
default: () => props.renderers || {},
Expand Down Expand Up @@ -87,6 +93,7 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {

return {
components: { ...parentContext.components, ...componentProps.components },
customComponents: { ...parentContext.customComponents, ...(componentProps.customComponents || {}) },
renderers: mergedRenderers,
middlewares: [...parentContext.middlewares, ...localMiddlewares],
debug: { ...parentContext.debug, ...componentProps.debug },
Expand All @@ -100,6 +107,7 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) {

return {
components: componentProps.components || {},
customComponents: componentProps.customComponents || {},
renderers: componentProps.renderers,
middlewares: localMiddlewares,
debug: mergedDebug,
Expand Down
7 changes: 5 additions & 2 deletions packages/adapters/vue/src/runtime-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
*/

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

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

// Extract children from props if present (Vue passes children as third argument to h())
Expand Down
48 changes: 25 additions & 23 deletions packages/factories/vue/src/components/DefaultFormContainer.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,59 @@
/**
* Default Form Container Component for Vue
*
*
* Built-in form container that wraps children in a <form> tag
* and renders a submit button. Can be overridden via createComponentSpec.
*/

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

/**
* Props for FormContainer component.
* Use this type when creating a custom FormContainer.
*/
export interface FormContainerProps {
/** Submit handler - when provided, renders a submit button */
onSubmit?: () => void;
onSubmit?: (values: Record<string, any>) => void | Promise<void>;
/** External context passed from FormFactory */
externalContext?: Record<string, any>;
/**
* Custom SubmitButton component - resolved by FormFactory from registry.
* If not provided, uses DefaultSubmitButton.
*/
SubmitButtonComponent?: SubmitButtonComponentType;
}

/**
* Default form container component for Vue.
*
*
* Renders children inside a <form> tag with an optional submit button.
*
* - When `onSubmit` is provided: renders submit button inside the form
* - When `onSubmit` is NOT provided: no submit button (for external submit via formRef)
* Uses ScheptaFormAdapter for form submission.
*/
export const DefaultFormContainer = defineComponent({
name: 'DefaultFormContainer',
props: {
onSubmit: {
type: Function as PropType<() => void>,
type: Function as PropType<(values: Record<string, any>) => void | Promise<void>>,
required: false,
},
},
setup(props, { slots }) {
const adapter = useScheptaFormAdapter();
return () => {
return h('form', {
'data-test-id': 'FormContainer',
onSubmit: (e: Event) => {
e.preventDefault();
props.onSubmit?.();
const handleSubmit = (e: Event) => {
e.preventDefault();
if (props.onSubmit) {
adapter.handleSubmit(props.onSubmit)();
}
}, [
slots.default?.(),
props.onSubmit && h(DefaultSubmitButton, { onSubmit: props.onSubmit })
]);
};
return h(
'form',
{
'data-test-id': 'FormContainer',
onSubmit: handleSubmit,
},
[
slots.default?.(),
props.onSubmit && h(DefaultSubmitButton),
]
);
};
}
},
});
31 changes: 31 additions & 0 deletions packages/factories/vue/src/components/DefaultFormField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Default FormField Component for Vue
*
* Wrapper (grid cell) for a single form field.
*/

import { defineComponent, h, type PropType } from 'vue';

export const DefaultFormField = defineComponent({
name: 'DefaultFormField',
props: {
'data-test-id': String,
'x-component-props': Object as PropType<Record<string, any>>,
'x-ui': Object as PropType<Record<string, any>>,
},
setup(props, { slots }) {
return () => {
const xProps = props['x-component-props'] || {};
const { style, ...restXProps } = xProps;
return h(
'div',
{
style,
'data-test-id': props['data-test-id'],
...restXProps,
},
slots.default?.()
);
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Default FormSectionContainer Component for Vue
*
* Container for a form section (title + groups).
*/

import { defineComponent, h } from 'vue';

export const DefaultFormSectionContainer = defineComponent({
name: 'DefaultFormSectionContainer',
setup(props, { slots }) {
return () =>
h(
'div',
{
style: { marginBottom: '24px' },
},
slots.default?.()
);
},
});
Loading