diff --git a/packages/adapters/vue/src/form-adapter.ts b/packages/adapters/vue/src/form-adapter.ts index 158ce18..8db92ce 100644 --- a/packages/adapters/vue/src/form-adapter.ts +++ b/packages/adapters/vue/src/form-adapter.ts @@ -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, 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, 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 */ @@ -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 { + 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 { 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): 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 { @@ -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]); } } @@ -108,16 +148,32 @@ export class VueFormAdapter implements FormAdapter { handleSubmit(onSubmit: (values: Record) => void | Promise): () => void { return () => { - if (this.isValid()) { - onSubmit(this.getValues()); + let hasErrors = false; + const newErrors: Record = {}; + + 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') { @@ -135,4 +191,3 @@ export class VueFormAdapter implements FormAdapter { export function createVueFormAdapter(initialValues?: Record): VueFormAdapter { return new VueFormAdapter(initialValues); } - diff --git a/packages/adapters/vue/src/provider.ts b/packages/adapters/vue/src/provider.ts index e791e2f..c8d9491 100644 --- a/packages/adapters/vue/src/provider.ts +++ b/packages/adapters/vue/src/provider.ts @@ -16,6 +16,7 @@ import { defaultDebugConfig } from '@schepta/core'; */ export interface ScheptaProviderProps { components?: Record; + customComponents?: Record; renderers?: Partial>; middlewares?: MiddlewareFn[]; debug?: DebugConfig; @@ -28,6 +29,7 @@ export interface ScheptaProviderProps { */ export interface ScheptaContextType { components: Record; + customComponents: Record; renderers: Record; middlewares: MiddlewareFn[]; debug: DebugConfig; @@ -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 || {}, @@ -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 }, @@ -100,6 +107,7 @@ export function createScheptaProvider(props: ScheptaProviderProps = {}) { return { components: componentProps.components || {}, + customComponents: componentProps.customComponents || {}, renderers: componentProps.renderers, middlewares: localMiddlewares, debug: mergedDebug, diff --git a/packages/adapters/vue/src/runtime-adapter.ts b/packages/adapters/vue/src/runtime-adapter.ts index 14766d3..4dffbf1 100644 --- a/packages/adapters/vue/src/runtime-adapter.ts +++ b/packages/adapters/vue/src/runtime-adapter.ts @@ -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): RenderResult { + create(spec: ComponentSpec | RendererSpec, props: Record): 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()) diff --git a/packages/factories/vue/src/components/DefaultFormContainer.ts b/packages/factories/vue/src/components/DefaultFormContainer.ts index 176cb12..3cbb355 100644 --- a/packages/factories/vue/src/components/DefaultFormContainer.ts +++ b/packages/factories/vue/src/components/DefaultFormContainer.ts @@ -1,12 +1,13 @@ /** * Default Form Container Component for Vue - * + * * Built-in form container that wraps children in a
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. @@ -14,44 +15,45 @@ import { DefaultSubmitButton, type SubmitButtonComponentType } from './DefaultSu */ export interface FormContainerProps { /** Submit handler - when provided, renders a submit button */ - onSubmit?: () => void; + onSubmit?: (values: Record) => void | Promise; /** External context passed from FormFactory */ externalContext?: Record; - /** - * 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 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) => void | Promise>, 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), + ] + ); }; - } + }, }); diff --git a/packages/factories/vue/src/components/DefaultFormField.ts b/packages/factories/vue/src/components/DefaultFormField.ts new file mode 100644 index 0000000..0d3d8d9 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultFormField.ts @@ -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>, + 'x-ui': Object as PropType>, + }, + 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?.() + ); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultFormSectionContainer.ts b/packages/factories/vue/src/components/DefaultFormSectionContainer.ts new file mode 100644 index 0000000..db6aab6 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultFormSectionContainer.ts @@ -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?.() + ); + }, +}); diff --git a/packages/factories/vue/src/components/DefaultFormSectionGroup.ts b/packages/factories/vue/src/components/DefaultFormSectionGroup.ts new file mode 100644 index 0000000..74a7806 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultFormSectionGroup.ts @@ -0,0 +1,28 @@ +/** + * Default FormSectionGroup Component for Vue + * + * Grid layout for a group of form fields. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +export const DefaultFormSectionGroup = defineComponent({ + name: 'DefaultFormSectionGroup', + props: { + style: Object as PropType>, + 'x-component-props': Object as PropType>, + }, + setup(props, { slots }) { + return () => { + const xProps = props['x-component-props'] || {}; + const baseStyle = { + display: 'grid', + gridTemplateColumns: xProps.columns || 'repeat(auto-fit, minmax(200px, 1fr))', + gap: '16px', + ...props.style, + ...xProps.style, + }; + return h('div', { style: baseStyle }, slots.default?.()); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultFormSectionGroupContainer.ts b/packages/factories/vue/src/components/DefaultFormSectionGroupContainer.ts new file mode 100644 index 0000000..22a2606 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultFormSectionGroupContainer.ts @@ -0,0 +1,14 @@ +/** + * Default FormSectionGroupContainer Component for Vue + * + * Container for FormSectionGroup(s). + */ + +import { defineComponent, h } from 'vue'; + +export const DefaultFormSectionGroupContainer = defineComponent({ + name: 'DefaultFormSectionGroupContainer', + setup(props, { slots }) { + return () => h('div', null, slots.default?.()); + }, +}); diff --git a/packages/factories/vue/src/components/DefaultFormSectionTitle.ts b/packages/factories/vue/src/components/DefaultFormSectionTitle.ts new file mode 100644 index 0000000..48b770b --- /dev/null +++ b/packages/factories/vue/src/components/DefaultFormSectionTitle.ts @@ -0,0 +1,29 @@ +/** + * Default FormSectionTitle Component for Vue + * + * Section title (x-content). + */ + +import { defineComponent, h, type PropType } from 'vue'; + +export const DefaultFormSectionTitle = defineComponent({ + name: 'DefaultFormSectionTitle', + props: { + 'x-content': String, + }, + setup(props, { slots }) { + return () => + h( + 'h2', + { + style: { + marginBottom: '16px', + fontSize: '20px', + fontWeight: '600', + color: '#333', + }, + }, + props['x-content'] ?? slots.default?.() + ); + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputAutocomplete.ts b/packages/factories/vue/src/components/DefaultInputAutocomplete.ts new file mode 100644 index 0000000..e99582d --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputAutocomplete.ts @@ -0,0 +1,90 @@ +/** + * Default InputAutocomplete Component for Vue + * + * Built-in autocomplete input for forms (uses native datalist). + */ + +import { defineComponent, h, type PropType } from 'vue'; + +export interface InputAutocompleteOption { + value: string; + label?: string; +} + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +function normalizeOptions( + options: InputAutocompleteOption[] | string[] = [] +): { value: string; label: string }[] { + return options.map((opt) => + typeof opt === 'string' + ? { value: opt, label: opt } + : { value: opt.value, label: opt.label ?? opt.value } + ); +} + +export const DefaultInputAutocomplete = defineComponent({ + name: 'DefaultInputAutocomplete', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + options: { + type: Array as PropType, + default: () => [], + }, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const listId = `${props.name}-datalist`; + const normalizedOptions = normalizeOptions(props.options || []); + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('input', { + id: props.name, + name: props.name, + list: listId, + value: String(value), + placeholder: props.placeholder, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const val = (e.target as HTMLInputElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + h( + 'datalist', + { id: listId }, + normalizedOptions.map((opt) => + h('option', { key: opt.value, value: opt.value }, opt.label) + ) + ), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputCheckbox.ts b/packages/factories/vue/src/components/DefaultInputCheckbox.ts new file mode 100644 index 0000000..b8e403a --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputCheckbox.ts @@ -0,0 +1,56 @@ +/** + * Default InputCheckbox Component for Vue + * + * Built-in checkbox input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const wrapperStyle = { marginBottom: '16px' }; + +const labelStyle = { + display: 'flex', + alignItems: 'center', + gap: '8px', +}; + +export const DefaultInputCheckbox = defineComponent({ + name: 'DefaultInputCheckbox', + props: { + name: { type: String, required: true }, + value: { type: [Boolean, String] as PropType, default: false }, + onChange: { type: Function as PropType<(v: boolean) => void> }, + label: String, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const checked = props.value === true || props.value === 'true'; + return h('div', { style: wrapperStyle }, [ + h( + 'label', + { style: labelStyle }, + [ + h('input', { + type: 'checkbox', + name: props.name, + checked, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + onChange: (e: Event) => { + const val = (e.target as HTMLInputElement).checked; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + props.label, + ] + ), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputDate.ts b/packages/factories/vue/src/components/DefaultInputDate.ts new file mode 100644 index 0000000..70beb4c --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputDate.ts @@ -0,0 +1,62 @@ +/** + * Default InputDate Component for Vue + * + * Built-in date input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputDate = defineComponent({ + name: 'DefaultInputDate', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('input', { + type: 'date', + id: props.name, + name: props.name, + value: String(value), + placeholder: props.placeholder, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const val = (e.target as HTMLInputElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputNumber.ts b/packages/factories/vue/src/components/DefaultInputNumber.ts new file mode 100644 index 0000000..8440340 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputNumber.ts @@ -0,0 +1,69 @@ +/** + * Default InputNumber Component for Vue + * + * Built-in number input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputNumber = defineComponent({ + name: 'DefaultInputNumber', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: number | string) => void> }, + label: String, + placeholder: String, + min: Number, + max: Number, + step: [Number, String] as PropType, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('input', { + type: 'number', + id: props.name, + name: props.name, + value: String(value), + placeholder: props.placeholder, + min: props.min, + max: props.max, + step: props.step, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const raw = (e.target as HTMLInputElement).value; + const val = raw ? Number(raw) : ''; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputPhone.ts b/packages/factories/vue/src/components/DefaultInputPhone.ts new file mode 100644 index 0000000..c2f7751 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputPhone.ts @@ -0,0 +1,62 @@ +/** + * Default InputPhone Component for Vue + * + * Built-in phone input for forms (type="tel"). + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputPhone = defineComponent({ + name: 'DefaultInputPhone', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('input', { + type: 'tel', + id: props.name, + name: props.name, + value: String(value), + placeholder: props.placeholder, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const val = (e.target as HTMLInputElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputSelect.ts b/packages/factories/vue/src/components/DefaultInputSelect.ts new file mode 100644 index 0000000..33c3393 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputSelect.ts @@ -0,0 +1,75 @@ +/** + * Default InputSelect Component for Vue + * + * Built-in select input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +export interface InputSelectOption { + value: string; + label: string; +} + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputSelect = defineComponent({ + name: 'DefaultInputSelect', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: { type: String, default: 'Select...' }, + options: { type: Array as PropType, default: () => [] }, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h( + 'select', + { + id: props.name, + name: props.name, + value: String(value), + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onChange: (e: Event) => { + const val = (e.target as HTMLSelectElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }, + [ + h('option', { value: '' }, props.placeholder), + ...(props.options || []).map((opt) => + h('option', { key: opt.value, value: opt.value }, opt.label) + ), + ] + ), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputText.ts b/packages/factories/vue/src/components/DefaultInputText.ts new file mode 100644 index 0000000..26d9b23 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputText.ts @@ -0,0 +1,63 @@ +/** + * Default InputText Component for Vue + * + * Built-in text input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputText = defineComponent({ + name: 'DefaultInputText', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + 'data-test-id': String, + type: { type: String, default: 'text' }, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('input', { + id: props.name, + name: props.name, + value: String(value), + placeholder: props.placeholder, + type: props.type, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const val = (e.target as HTMLInputElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultInputTextarea.ts b/packages/factories/vue/src/components/DefaultInputTextarea.ts new file mode 100644 index 0000000..ee6d2b8 --- /dev/null +++ b/packages/factories/vue/src/components/DefaultInputTextarea.ts @@ -0,0 +1,64 @@ +/** + * Default InputTextarea Component for Vue + * + * Built-in textarea input for forms. + */ + +import { defineComponent, h, type PropType } from 'vue'; + +const inputStyle = { + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', + fontFamily: 'inherit', +}; + +const labelStyle = { + display: 'block', + marginBottom: '4px', + fontWeight: '500', +}; + +const wrapperStyle = { marginBottom: '16px' }; + +export const DefaultInputTextarea = defineComponent({ + name: 'DefaultInputTextarea', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + rows: { type: Number, default: 4 }, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h('div', { style: wrapperStyle }, [ + props.label && + h('label', { for: props.name, style: labelStyle }, props.label), + h('textarea', { + id: props.name, + name: props.name, + value: String(value), + placeholder: props.placeholder, + rows: props.rows, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + style: inputStyle, + onInput: (e: Event) => { + const val = (e.target as HTMLTextAreaElement).value; + props.onChange?.(val); + emit('update:modelValue', val); + }, + }), + ]); + }; + }, +}); diff --git a/packages/factories/vue/src/components/DefaultSubmitButton.ts b/packages/factories/vue/src/components/DefaultSubmitButton.ts index 7a6b444..ec3a7bf 100644 --- a/packages/factories/vue/src/components/DefaultSubmitButton.ts +++ b/packages/factories/vue/src/components/DefaultSubmitButton.ts @@ -1,7 +1,7 @@ /** * Default Submit Button Component for Vue - * - * Built-in submit button for forms. Can be overridden via createComponentSpec. + * + * Built-in submit button for forms. Uses type="submit" to trigger form submission. */ import { defineComponent, h, type PropType, type Component } from 'vue'; @@ -11,7 +11,7 @@ import { defineComponent, h, type PropType, type Component } from 'vue'; * Use this type when creating a custom SubmitButton. */ export interface SubmitButtonProps { - /** Submit handler - triggers form submission */ + /** Submit handler - triggers form submission (when used standalone) */ onSubmit?: () => void; } @@ -22,41 +22,27 @@ export type SubmitButtonComponentType = Component; /** * Default submit button component for Vue - * Can be overridden via components prop or ScheptaProvider + * Uses type="submit" to trigger parent form's submit handler */ export const DefaultSubmitButton = defineComponent({ name: 'DefaultSubmitButton', - props: { - onSubmit: { - type: Function as PropType<() => void>, - required: false, - }, + setup() { + return () => + h('div', { style: { marginTop: '24px', textAlign: 'right' } }, [ + h('button', { + type: 'submit', + 'data-test-id': 'submit-button', + style: { + padding: '12px 24px', + backgroundColor: '#007bff', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '16px', + fontWeight: '500', + }, + }, 'Submit'), + ]); }, - setup(props) { - const handleClick = () => { - if (props.onSubmit) { - props.onSubmit(); - } - }; - - return () => h('div', { - style: { marginTop: '24px', textAlign: 'right' } - }, [ - h('button', { - type: 'button', - onClick: handleClick, - 'data-test-id': 'submit-button', - style: { - padding: '12px 24px', - backgroundColor: '#007bff', - color: 'white', - border: 'none', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '16px', - fontWeight: '500', - } - }, 'Submit') - ]); - } }); diff --git a/packages/factories/vue/src/components/index.ts b/packages/factories/vue/src/components/index.ts index b74d8c2..5618e65 100644 --- a/packages/factories/vue/src/components/index.ts +++ b/packages/factories/vue/src/components/index.ts @@ -1,8 +1,21 @@ /** * Vue Factory Components - * + * * Built-in components for the Vue form factory. */ export * from './DefaultFormContainer'; +export * from './DefaultFormField'; +export * from './DefaultFormSectionContainer'; +export * from './DefaultFormSectionGroup'; +export * from './DefaultFormSectionGroupContainer'; +export * from './DefaultFormSectionTitle'; +export * from './DefaultInputAutocomplete'; +export * from './DefaultInputCheckbox'; +export * from './DefaultInputDate'; +export * from './DefaultInputNumber'; +export * from './DefaultInputPhone'; +export * from './DefaultInputSelect'; +export * from './DefaultInputText'; +export * from './DefaultInputTextarea'; export * from './DefaultSubmitButton'; diff --git a/packages/factories/vue/src/context/schepta-form-context.ts b/packages/factories/vue/src/context/schepta-form-context.ts new file mode 100644 index 0000000..652e5f0 --- /dev/null +++ b/packages/factories/vue/src/context/schepta-form-context.ts @@ -0,0 +1,111 @@ +/** + * Schepta Form Context + * + * Provides form adapter to child components without external dependencies. + * This is the native form state management system for Schepta. + */ + +import { provide, inject, defineComponent, h, type InjectionKey } from 'vue'; +import type { FormAdapter } from '@schepta/core'; + +/** + * Context type for Schepta form adapter + */ +export interface ScheptaFormContextType { + /** The form adapter instance */ + adapter: FormAdapter; + /** Current form values (for reactivity) */ + values: Record; +} + +const SCHEPTA_FORM_KEY: InjectionKey = Symbol('schepta-form'); + +/** + * Props for ScheptaFormProvider + */ +export interface ScheptaFormProviderProps { + /** External adapter (from FormFactory) */ + adapter: FormAdapter; + /** Current form values (for reactivity) */ + values: Record; +} + +/** + * Provider component that wraps form content and provides the form adapter. + */ +export const ScheptaFormProvider = defineComponent({ + name: 'ScheptaFormProvider', + props: { + adapter: { + type: Object as () => FormAdapter, + required: true, + }, + values: { + type: Object as () => Record, + required: true, + }, + }, + setup(props, { slots }) { + // Provide reactive context that updates when props change + const contextValue = { + get adapter() { + return props.adapter; + }, + get values() { + return props.values; + }, + }; + provide(SCHEPTA_FORM_KEY, contextValue); + return () => slots.default?.(); + }, +}); + +/** + * Composable to access the form adapter from context. + * + * @returns The form adapter instance + * @throws Error if used outside of ScheptaFormProvider + */ +export function useScheptaFormAdapter(): FormAdapter { + const context = inject(SCHEPTA_FORM_KEY); + if (!context) { + throw new Error( + 'useScheptaFormAdapter must be used within a ScheptaFormProvider. ' + + 'Make sure your component is wrapped with ScheptaFormProvider or FormFactory.' + ); + } + return context.adapter; +} + +/** + * Composable to access form values reactively. + * + * @returns Current form values + */ +export function useScheptaFormValues(): Record { + const context = inject(SCHEPTA_FORM_KEY); + if (!context) { + throw new Error('useScheptaFormValues must be used within a ScheptaFormProvider.'); + } + return context.values; +} + +/** + * Composable to get a specific field value reactively. + * + * @param field - The field name (supports dot notation for nested fields) + * @returns The field value + */ +export function useScheptaFieldValue(field: string): any { + const context = inject(SCHEPTA_FORM_KEY); + if (!context) { + throw new Error('useScheptaFieldValue must be used within a ScheptaFormProvider.'); + } + const parts = field.split('.'); + let value: any = context.values; + for (const part of parts) { + if (value === undefined || value === null) return undefined; + value = value[part]; + } + return value; +} diff --git a/packages/factories/vue/src/defaults/register-default-components.ts b/packages/factories/vue/src/defaults/register-default-components.ts new file mode 100644 index 0000000..cb62fd1 --- /dev/null +++ b/packages/factories/vue/src/defaults/register-default-components.ts @@ -0,0 +1,96 @@ +import { createComponentSpec } from '@schepta/core'; +import { + DefaultFormContainer, + DefaultFormField, + DefaultFormSectionContainer, + DefaultFormSectionGroup, + DefaultFormSectionGroupContainer, + DefaultFormSectionTitle, + DefaultInputAutocomplete, + DefaultInputCheckbox, + DefaultInputDate, + DefaultInputNumber, + DefaultInputPhone, + DefaultInputSelect, + DefaultInputText, + DefaultInputTextarea, + DefaultSubmitButton, +} from '../components'; + +export const defaultComponents = { + FormContainer: createComponentSpec({ + id: 'FormContainer', + type: 'container', + component: () => DefaultFormContainer, + }), + SubmitButton: createComponentSpec({ + id: 'SubmitButton', + type: 'button', + component: () => DefaultSubmitButton, + }), + InputText: createComponentSpec({ + id: 'InputText', + type: 'field', + component: () => DefaultInputText, + }), + InputSelect: createComponentSpec({ + id: 'InputSelect', + type: 'field', + component: () => DefaultInputSelect, + }), + InputCheckbox: createComponentSpec({ + id: 'InputCheckbox', + type: 'field', + component: () => DefaultInputCheckbox, + }), + InputDate: createComponentSpec({ + id: 'InputDate', + type: 'field', + component: () => DefaultInputDate, + }), + InputPhone: createComponentSpec({ + id: 'InputPhone', + type: 'field', + component: () => DefaultInputPhone, + }), + InputAutocomplete: createComponentSpec({ + id: 'InputAutocomplete', + type: 'field', + component: () => DefaultInputAutocomplete, + }), + InputTextarea: createComponentSpec({ + id: 'InputTextarea', + type: 'field', + component: () => DefaultInputTextarea, + }), + InputNumber: createComponentSpec({ + id: 'InputNumber', + type: 'field', + component: () => DefaultInputNumber, + }), + FormField: createComponentSpec({ + id: 'FormField', + type: 'container', + component: () => DefaultFormField, + }), + FormSectionContainer: createComponentSpec({ + id: 'FormSectionContainer', + type: 'container', + component: () => DefaultFormSectionContainer, + }), + FormSectionTitle: createComponentSpec({ + id: 'FormSectionTitle', + type: 'content', + component: () => DefaultFormSectionTitle, + }), + FormSectionGroup: createComponentSpec({ + id: 'FormSectionGroup', + type: 'container', + component: () => DefaultFormSectionGroup, + }), + FormSectionGroupContainer: createComponentSpec({ + id: 'FormSectionGroupContainer', + type: 'container', + component: () => DefaultFormSectionGroupContainer, + }), +}; diff --git a/packages/factories/vue/src/defaults/register-default-renderers.ts b/packages/factories/vue/src/defaults/register-default-renderers.ts new file mode 100644 index 0000000..21a2081 --- /dev/null +++ b/packages/factories/vue/src/defaults/register-default-renderers.ts @@ -0,0 +1,10 @@ +import { createRendererSpec } from '@schepta/core'; +import { DefaultFieldRenderer } from '../renderers/DefaultFieldRenderer'; + +export const defaultRenderers = { + field: createRendererSpec({ + id: 'field', + type: 'field', + component: () => DefaultFieldRenderer as any, + }), +}; diff --git a/packages/factories/vue/src/form-factory.ts b/packages/factories/vue/src/form-factory.ts index dfdbea4..ec6769e 100644 --- a/packages/factories/vue/src/form-factory.ts +++ b/packages/factories/vue/src/form-factory.ts @@ -1,243 +1,252 @@ /** * Vue Form Factory + * + * Factory component for rendering forms from JSON schemas. + * API aligned with React FormFactory. */ -import { defineComponent, ref, computed, watch, h, type PropType } from 'vue'; -import type { FormSchema, ComponentSpec, RendererFn, RuntimeAdapter } from '@schepta/core'; +import { defineComponent, ref, computed, watch, reactive, h, type PropType } from 'vue'; +import type { + FormSchema, + ComponentSpec, + MiddlewareFn, + FormAdapter, +} from '@schepta/core'; import { createVueRuntimeAdapter } from '@schepta/adapter-vue'; import { createVueFormAdapter } from '@schepta/adapter-vue'; import { useScheptaContext } from '@schepta/adapter-vue'; -import { - createComponentOrchestrator, +import { + createComponentOrchestrator, type FactorySetupResult, setFactoryDefaultComponents, - createComponentSpec, + setFactoryDefaultRenderers, + getFactoryDefaultComponents, + getFactoryDefaultRenderers, + defaultRenderers, + createTemplateExpressionMiddleware, + buildInitialValues, + createSchemaValidator, + formatValidationErrors, } from '@schepta/core'; -import { buildInitialValues } from '@schepta/core'; +import formSchemaDefinition from '../../src/schemas/form-schema.json'; import { FormRenderer } from './form-renderer'; -import { DefaultFormContainer, DefaultSubmitButton } from './components'; - -// Register factory default components (called once on module load) -setFactoryDefaultComponents({ - FormContainer: createComponentSpec({ - id: 'FormContainer', - type: 'container', - component: () => DefaultFormContainer, - }), - SubmitButton: createComponentSpec({ - id: 'SubmitButton', - type: 'content', - component: () => DefaultSubmitButton, - }), -}); +import { ScheptaFormProvider } from './context/schepta-form-context'; +import { defaultComponents } from './defaults/register-default-components'; +import { defaultRenderers as vueDefaultRenderers } from './defaults/register-default-renderers'; + +// Register factory default components and renderers (called once on module load) +setFactoryDefaultComponents(defaultComponents); +setFactoryDefaultRenderers(vueDefaultRenderers); /** * Ref interface for external form control */ export interface FormFactoryRef { - /** Submit the form with the provided handler */ submit: (onSubmit: (values: Record) => void | Promise) => void; - /** Reset form to initial or provided values */ reset: (values?: Record) => void; - /** Get current form values */ getValues: () => Record; } -// Custom field renderer that integrates fields with VueFormAdapter -const createFieldRenderer = (formAdapter: any): RendererFn => { - return (spec: ComponentSpec, props: Record, runtime: RuntimeAdapter, children?: any[]) => { - const name = props.name || ''; - - // If this is a field component and we have a name, bind it to form adapter - if (name && spec.type === 'field') { - // Extract component props - x-ui contains label, placeholder, etc. - // Note: x-ui props are already spread into props by the orchestrator - // So we can access label, placeholder directly from props - const xUI = props['x-ui'] || {}; - const xComponentProps = props['x-component-props'] || {}; - - // Build component props - spread x-ui props directly (they're already in props from orchestrator) - // But also check x-ui object in case it wasn't spread - const componentProps = { - ...xComponentProps, - // Get label/placeholder from props directly (orchestrator spreads x-ui) or from x-ui object - label: props.label || xUI.label, - placeholder: props.placeholder || xUI.placeholder, - ...xUI, // Spread any other x-ui props - name, - value: formAdapter.getValue(name), - modelValue: formAdapter.getValue(name), - onChange: (value: any) => { - formAdapter.setValue(name, value); - }, - 'onUpdate:modelValue': (value: any) => { - formAdapter.setValue(name, value); - }, - }; - - // Clean up metadata props that shouldn't be passed to the component - delete componentProps['x-component-props']; - delete componentProps['x-ui']; - - return runtime.create(spec, componentProps); - } - - // For non-field components, use default rendering - const xComponentProps = props['x-component-props'] || {}; - const propsWithChildren = children && children.length > 0 - ? { ...props, ...xComponentProps, children } - : { ...props, ...xComponentProps }; - - delete propsWithChildren['x-component-props']; - delete propsWithChildren['x-ui']; - - return runtime.create(spec, propsWithChildren); - }; -}; - export interface FormFactoryProps { schema: FormSchema; components?: Record; + customComponents?: Record; renderers?: Partial>; externalContext?: Record; + middlewares?: MiddlewareFn[]; + adapter?: FormAdapter; initialValues?: Record; onSubmit?: (values: Record) => void | Promise; debug?: boolean; } -export function createFormFactory(defaultProps: FormFactoryProps) { - return defineComponent({ - name: 'FormFactory', - props: { - schema: { - type: Object as () => FormSchema, - required: false, - default: () => defaultProps.schema, - }, +function createDebugContext(enabled: boolean) { + if (!enabled) return undefined; + return { + isEnabled: true, + log: (category: string, message: string, data?: any) => { + console.log(`[${category}]`, message, data); + }, + buffer: { add: () => {}, clear: () => {}, getAll: () => [] }, + }; +} + +export const FormFactory = defineComponent({ + name: 'FormFactory', + props: { + schema: { type: Object as PropType, required: true }, + components: { type: Object as PropType>, default: () => ({}) }, + customComponents: { type: Object as PropType>, default: () => ({}) }, + renderers: { type: Object, default: () => ({}) }, + externalContext: { type: Object, default: () => ({}) }, + middlewares: { type: Array as PropType, default: () => [] }, + adapter: { type: Object as PropType, default: undefined }, + initialValues: { type: Object, default: undefined }, + onSubmit: { type: Function as PropType<(values: Record) => void | Promise>, default: undefined }, + debug: { type: Boolean, default: false }, + }, + setup(props, { expose }) { + const providerConfig = useScheptaContext(); + + const mergedConfig = computed(() => ({ components: { - type: Object, - required: false, - default: () => defaultProps.components || {}, + ...getFactoryDefaultComponents(), + ...(providerConfig?.components || {}), + ...(props.components || {}), + }, + customComponents: { + ...(providerConfig?.customComponents || {}), + ...(props.customComponents || {}), }, renderers: { - type: Object, - required: false, - default: () => defaultProps.renderers || {}, + ...defaultRenderers, + ...getFactoryDefaultRenderers(), + ...(providerConfig?.renderers || {}), + ...(props.renderers || {}), }, externalContext: { - type: Object, - required: false, - default: () => defaultProps.externalContext || {}, - }, - initialValues: { - type: Object, - required: false, - default: () => defaultProps.initialValues, - }, - onSubmit: { - type: [Function, undefined] as any, - required: false, - default: () => defaultProps.onSubmit, - }, - debug: { - type: Boolean, - required: false, - default: () => defaultProps.debug || false, - }, - }, - setup(props, { expose }) { - // Get provider config (optional - returns null if no provider) - const providerConfig = useScheptaContext(); - - // Use props if provided, otherwise fall back to defaultProps - const schema = props.schema || defaultProps.schema; - - // Merge: local props > provider config > defaults - const mergedComponents = props.components || defaultProps.components || providerConfig?.components || {}; - const mergedRenderers = props.renderers || defaultProps.renderers || providerConfig?.renderers || {}; - const mergedMiddlewares = providerConfig?.middlewares || []; - const mergedExternalContext = { ...(providerConfig?.externalContext || {}), - ...(props.externalContext || defaultProps.externalContext || {}), - }; - const mergedDebug = props.debug !== undefined - ? props.debug - : (defaultProps.debug !== undefined ? defaultProps.debug : (providerConfig?.debug?.enabled || false)); - - - const formAdapter = ref(createVueFormAdapter( - props.initialValues || buildInitialValues(props.schema) - )); - const runtime = ref(createVueRuntimeAdapter()); - - // Expose form control methods via ref for external submit scenarios - expose({ - submit: (submitFn: (values: Record) => void | Promise) => - formAdapter.value.handleSubmit(submitFn)(), - reset: (values?: Record) => formAdapter.value.reset(values), - getValues: () => formAdapter.value.getValues(), - } as FormFactoryRef); + ...(props.externalContext || {}), + }, + baseMiddlewares: [ + ...(providerConfig?.middlewares || []), + ...(props.middlewares || []), + ], + debug: + props.debug !== undefined + ? props.debug + : (providerConfig?.debug?.enabled || false), + })); - const getFactorySetup = (): FactorySetupResult => { - // Create custom renderers with field renderer - const customRenderers = { - ...mergedRenderers, - field: createFieldRenderer(formAdapter.value), + const validation = computed(() => { + try { + const validator = createSchemaValidator(formSchemaDefinition as object, { throwOnError: false }); + const result = validator(props.schema); + return { + valid: result.valid, + formattedErrors: result.valid ? '' : formatValidationErrors(result.errors), }; - + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; return { - components: mergedComponents, - renderers: customRenderers, - externalContext: { - ...mergedExternalContext, - onSubmit: props.onSubmit, - }, - state: formAdapter.value.getValues(), - middlewares: mergedMiddlewares, - onSubmit: props.onSubmit ? () => formAdapter.value.handleSubmit(props.onSubmit!)() : undefined, - debug: mergedDebug ? { - isEnabled: true, - log: (category, message, data) => { - console.log(`[${category}]`, message, data); - }, - buffer: { - add: () => {}, - clear: () => {}, - getAll: () => [], - }, - } : undefined, - formAdapter: formAdapter.value, + valid: false, + formattedErrors: `Schema compilation error: ${msg}`, }; - }; + } + }); + + const defaultValues = computed(() => { + const schemaDefaults = buildInitialValues(props.schema); + return { ...schemaDefaults, ...(props.initialValues || {}) }; + }); - const renderer = computed(() => - createComponentOrchestrator(getFactorySetup, runtime.value) - ); + const formAdapter = ref( + props.adapter || createVueFormAdapter(defaultValues.value) + ); - const rootComponentKey = computed(() => (props.schema as any)['x-component'] || 'FormContainer'); + // Reactive form values - get the reactive object directly from the adapter + // The adapter exposes its reactive values via the .values property + const formValues = computed(() => { + const adapter = formAdapter.value as any; + return adapter.values || adapter.getState?.() || adapter.getValues(); + }); - // Watch form state to trigger reactivity - watch(() => formAdapter.value.getValues(), () => { - // Force re-render when form values change - }, { deep: true }); + const reset = (values?: Record) => { + const resetValues = values || defaultValues.value; + formAdapter.value.reset(resetValues); + }; - // Watch for schema changes - watch(() => props.schema, (newSchema) => { - if (props.initialValues) { - formAdapter.value.reset(props.initialValues); - } else if (newSchema) { - formAdapter.value.reset(buildInitialValues(newSchema as FormSchema)); + watch( + () => [props.initialValues, props.schema] as const, + () => { + if (props.initialValues !== undefined) { + const newDefaults = { + ...buildInitialValues(props.schema), + ...props.initialValues, + }; + reset(newDefaults); } - }, { deep: true }); + }, + { deep: true } + ); - return () => { - return h(FormRenderer, { - componentKey: rootComponentKey.value, - schema: props.schema, - renderer: renderer.value, + expose({ + submit: (submitFn: (values: Record) => void | Promise) => + formAdapter.value.handleSubmit(submitFn)(), + reset, + getValues: () => formAdapter.value.getValues(), + } as FormFactoryRef); + + const runtime = ref(createVueRuntimeAdapter()); + + const rootComponentKey = computed( + () => (props.schema as any)['x-component'] || 'FormContainer' + ); + + const renderer = computed(() => { + const config = mergedConfig.value; + const debugContext = createDebugContext(config.debug); + + const getFactorySetup = (): FactorySetupResult => { + const templateMiddleware = createTemplateExpressionMiddleware({ + externalContext: { ...config.externalContext, onSubmit: props.onSubmit }, + formValues: formValues.value, + debug: debugContext, }); + + const updatedMiddlewares = [templateMiddleware, ...config.baseMiddlewares]; + + const state = formValues.value; + return { + components: config.components, + customComponents: config.customComponents, + renderers: config.renderers, + externalContext: { ...config.externalContext, onSubmit: props.onSubmit }, + state, + middlewares: updatedMiddlewares, + onSubmit: props.onSubmit, + debug: debugContext, + formAdapter: formAdapter.value, + }; }; - }, - }); -} + return createComponentOrchestrator(getFactorySetup, runtime.value); + }); + + return () => { + if (!validation.value.valid) { + return h('div', { + style: { + padding: '16px', + backgroundColor: '#fff0f0', + border: '1px solid #ffcccc', + borderRadius: '4px', + fontFamily: 'monospace', + }, + }, [ + h('h3', { style: { color: '#cc0000', margin: '0 0 12px 0' } }, 'Schema Validation Error'), + h('pre', { + style: { + whiteSpace: 'pre-wrap', + fontSize: '12px', + margin: 0, + color: '#660000', + }, + }, validation.value.formattedErrors), + ]); + } + + return h(ScheptaFormProvider, { + adapter: formAdapter.value, + values: formValues.value as Record, + }, { + default: () => + h(FormRenderer, { + componentKey: rootComponentKey.value, + schema: props.schema, + renderer: renderer.value, + }), + }); + }; + }, +}); diff --git a/packages/factories/vue/src/index.ts b/packages/factories/vue/src/index.ts index 7e79add..242080b 100644 --- a/packages/factories/vue/src/index.ts +++ b/packages/factories/vue/src/index.ts @@ -1,22 +1,42 @@ /** * @schepta/factory-vue - * - * Vue factories for schepta rendering engine + * + * Vue factories for schepta rendering engine. + * API aligned with @schepta/factory-react. */ -// Main factory export { - createFormFactory, + FormFactory, type FormFactoryProps, type FormFactoryRef, } from './form-factory'; -// Components (types and defaults) export { DefaultFormContainer, + DefaultFormField, + DefaultFormSectionContainer, + DefaultFormSectionGroup, + DefaultFormSectionGroupContainer, + DefaultFormSectionTitle, + DefaultInputAutocomplete, + DefaultInputCheckbox, + DefaultInputDate, + DefaultInputNumber, + DefaultInputPhone, + DefaultInputSelect, + DefaultInputText, + DefaultInputTextarea, DefaultSubmitButton, - type FormContainerProps, - type SubmitButtonProps, - type SubmitButtonComponentType, } from './components'; +export { DefaultFieldRenderer } from './renderers/DefaultFieldRenderer'; + +export { + ScheptaFormProvider, + useScheptaFormAdapter, + useScheptaFormValues, + useScheptaFieldValue, + type ScheptaFormProviderProps, +} from './context/schepta-form-context'; + +export { FormRenderer } from './form-renderer'; diff --git a/packages/factories/vue/src/renderers/DefaultFieldRenderer.ts b/packages/factories/vue/src/renderers/DefaultFieldRenderer.ts new file mode 100644 index 0000000..122c175 --- /dev/null +++ b/packages/factories/vue/src/renderers/DefaultFieldRenderer.ts @@ -0,0 +1,59 @@ +/** + * Default Field Renderer Component for Vue + * + * Renders field components with native Schepta form adapter binding. + */ + +import { defineComponent, h, computed, type PropType } from 'vue'; +import { useScheptaFormAdapter, useScheptaFormValues } from '../context/schepta-form-context'; +import type { FieldRendererProps } from '@schepta/core'; + +/** + * Default field renderer - uses native Schepta form adapter. + */ +export const DefaultFieldRenderer = defineComponent({ + name: 'DefaultFieldRenderer', + props: { + name: { type: String, required: true }, + component: { type: [Object, Function] as PropType, required: true }, + componentProps: { + type: Object as PropType>, + default: () => ({}), + }, + children: [Array, Object, String], + }, + setup(props) { + const adapter = useScheptaFormAdapter(); + const formValues = useScheptaFormValues(); + + const value = computed(() => { + const parts = props.name.split('.'); + let current: any = formValues; + for (const part of parts) { + if (current === undefined || current === null) return undefined; + current = current[part]; + } + return current; + }); + + const handleChange = (newValue: any) => { + adapter.setValue(props.name, newValue); + }; + + return () => { + const Component = props.component; + const displayValue = + value.value !== undefined && value.value !== null + ? value.value + : (props.componentProps?.value ?? ''); + return h(Component, { + ...props.componentProps, + name: props.name, + value: displayValue, + modelValue: displayValue, + onChange: handleChange, + 'onUpdate:modelValue': handleChange, + }); + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6be8abe..6da9b72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,18 +301,27 @@ importers: '@hookform/resolvers': specifier: ^3.9.0 version: 3.10.0(react-hook-form@7.68.0) + '@mdi/font': + specifier: ^7.4.47 + version: 7.4.47 '@mui/material': specifier: ^5.15.0 version: 5.18.0(@emotion/react@11.14.0)(@emotion/styled@11.14.1)(@types/react@18.3.27)(react-dom@18.3.1)(react@18.3.1) '@schepta/adapter-react': specifier: workspace:* version: link:../packages/adapters/react + '@schepta/adapter-vue': + specifier: workspace:* + version: link:../packages/adapters/vue '@schepta/core': specifier: workspace:* version: link:../packages/core '@schepta/factory-react': specifier: workspace:* version: link:../packages/factories/react + '@schepta/factory-vue': + specifier: workspace:* + version: link:../packages/factories/vue '@tanstack/react-router': specifier: ^1.159.5 version: 1.159.5(react-dom@18.3.1)(react@18.3.1) @@ -346,6 +355,9 @@ importers: vue: specifier: ^3.4.0 version: 3.5.25(typescript@5.9.3) + vuetify: + specifier: ^3.11.2 + version: 3.11.2(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.25) devDependencies: '@types/react': specifier: ^18.2.47 diff --git a/showcases/package.json b/showcases/package.json index 082cdd3..fab0bd5 100644 --- a/showcases/package.json +++ b/showcases/package.json @@ -12,10 +12,13 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@hookform/resolvers": "^3.9.0", + "@mdi/font": "^7.4.47", "@mui/material": "^5.15.0", "@schepta/adapter-react": "workspace:*", + "@schepta/adapter-vue": "workspace:*", "@schepta/core": "workspace:*", "@schepta/factory-react": "workspace:*", + "@schepta/factory-vue": "workspace:*", "@tanstack/react-router": "^1.159.5", "formik": "^2.4.6", "framer-motion": "^10.16.16", @@ -26,7 +29,8 @@ "single-spa": "^6.0.1", "single-spa-react": "^6.0.0", "single-spa-vue": "^3.0.1", - "vue": "^3.4.0" + "vue": "^3.4.0", + "vuetify": "^3.11.2" }, "devDependencies": { "@types/react": "^18.2.47", diff --git a/showcases/src/frameworks/vue/components/NativeComplexForm.vue b/showcases/src/frameworks/vue/components/NativeComplexForm.vue new file mode 100644 index 0000000..722d482 --- /dev/null +++ b/showcases/src/frameworks/vue/components/NativeComplexForm.vue @@ -0,0 +1,142 @@ + + + diff --git a/showcases/src/frameworks/vue/components/NativeForm.vue b/showcases/src/frameworks/vue/components/NativeForm.vue new file mode 100644 index 0000000..5f76286 --- /dev/null +++ b/showcases/src/frameworks/vue/components/NativeForm.vue @@ -0,0 +1,62 @@ + + + diff --git a/showcases/src/frameworks/vue/components/SocialNameInput.vue b/showcases/src/frameworks/vue/components/SocialNameInput.vue new file mode 100644 index 0000000..aa78dc0 --- /dev/null +++ b/showcases/src/frameworks/vue/components/SocialNameInput.vue @@ -0,0 +1,83 @@ + + + diff --git a/showcases/src/frameworks/vue/components/VueFormPage.vue b/showcases/src/frameworks/vue/components/VueFormPage.vue index 9ab7ad4..68b900e 100644 --- a/showcases/src/frameworks/vue/components/VueFormPage.vue +++ b/showcases/src/frameworks/vue/components/VueFormPage.vue @@ -13,6 +13,7 @@ v-for="(tab, index) in tabs" :key="tab.id" :class="['tab', { active: activeTab === index }]" + :data-test-id="tab.testId" @click="activeTab = index" > {{ tab.label }} @@ -21,16 +22,15 @@
-

Simple Vue Form

-

Esta é uma demonstração básica do Schepta no Vue (monorepo unificado)

-
-

✅ Funcionando! Este microfrontend Vue está rodando dentro do shell principal.

-
+
-

Complex Vue Form

-

Aqui seria um formulário mais complexo com Schepta

+ +
+ +
+
@@ -39,12 +39,22 @@ @@ -76,4 +86,4 @@ h3 { margin-bottom: 0.5rem; color: #333; } - \ No newline at end of file + diff --git a/showcases/src/frameworks/vue/index.ts b/showcases/src/frameworks/vue/index.ts index a839bc7..36f5955 100644 --- a/showcases/src/frameworks/vue/index.ts +++ b/showcases/src/frameworks/vue/index.ts @@ -1,12 +1,16 @@ import { createApp, h } from 'vue'; import VueFormPage from './components/VueFormPage.vue'; import singleSpaVue from 'single-spa-vue'; +import { vuetify } from './plugins/vuetify'; const lifecycles = singleSpaVue({ createApp, appOptions: { render: () => h(VueFormPage), }, + handleInstance: (app) => { + app.use(vuetify); + }, }); export const { bootstrap, mount, unmount } = lifecycles; \ No newline at end of file diff --git a/showcases/src/frameworks/vue/plugins/vuetify.ts b/showcases/src/frameworks/vue/plugins/vuetify.ts new file mode 100644 index 0000000..81993de --- /dev/null +++ b/showcases/src/frameworks/vue/plugins/vuetify.ts @@ -0,0 +1,30 @@ +/** + * Vuetify Plugin Configuration + */ + +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import 'vuetify/styles'; +import '@mdi/font/css/materialdesignicons.css'; + +export const vuetify = createVuetify({ + components, + directives, + theme: { + defaultTheme: 'light', + themes: { + light: { + colors: { + primary: '#1976d2', + secondary: '#424242', + accent: '#82b1ff', + error: '#ff5252', + info: '#2196f3', + success: '#4caf50', + warning: '#fb8c00', + }, + }, + }, + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/ComponentRegistry.ts b/showcases/src/frameworks/vue/vuetify/components/ComponentRegistry.ts new file mode 100644 index 0000000..476765f --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/ComponentRegistry.ts @@ -0,0 +1,88 @@ +import { createComponentSpec } from '@schepta/core'; +import { InputText } from './Inputs/InputText'; +import { InputSelect } from './Inputs/InputSelect'; +import { InputCheckbox } from './Inputs/InputCheckbox'; +import { InputTextarea } from './Inputs/InputTextarea'; +import { InputNumber } from './Inputs/InputNumber'; +import { InputDate } from './Inputs/InputDate'; +import { SubmitButton } from './SubmitButton'; +import { FormContainer } from './Containers/FormContainer'; +import { FormField } from './Containers/FormField'; +import { FormSectionContainer } from './Containers/FormSectionContainer'; +import { FormSectionTitle } from './Containers/FormSectionTitle'; +import { FormSectionGroupContainer } from './Containers/FormSectionGroupContainer'; +import { FormSectionGroup } from './Containers/FormSectionGroup'; + +export const components = { + FormContainer: createComponentSpec({ + id: 'FormContainer-vuetify', + type: 'container', + component: (props, runtime) => FormContainer, + }), + InputText: createComponentSpec({ + id: 'InputText-vuetify', + type: 'field', + component: (props, runtime) => InputText, + }), + InputSelect: createComponentSpec({ + id: 'InputSelect-vuetify', + type: 'field', + component: (props, runtime) => InputSelect, + }), + InputCheckbox: createComponentSpec({ + id: 'InputCheckbox-vuetify', + type: 'field', + component: (props, runtime) => InputCheckbox, + }), + InputPhone: createComponentSpec({ + id: 'InputPhone-vuetify', + type: 'field', + component: (props, runtime) => InputText, + defaultProps: { type: 'tel' }, + }), + InputTextarea: createComponentSpec({ + id: 'InputTextarea-vuetify', + type: 'field', + component: (props, runtime) => InputTextarea, + }), + InputNumber: createComponentSpec({ + id: 'InputNumber-vuetify', + type: 'field', + component: (props, runtime) => InputNumber, + }), + InputDate: createComponentSpec({ + id: 'InputDate-vuetify', + type: 'field', + component: (props, runtime) => InputDate, + }), + SubmitButton: createComponentSpec({ + id: 'SubmitButton-vuetify', + type: 'content', + component: (props, runtime) => SubmitButton, + }), + FormField: createComponentSpec({ + id: 'FormField-vuetify', + type: 'container', + component: (props, runtime) => FormField, + }), + FormSectionContainer: createComponentSpec({ + id: 'FormSectionContainer-vuetify', + type: 'container', + component: (props, runtime) => FormSectionContainer, + }), + FormSectionTitle: createComponentSpec({ + id: 'FormSectionTitle-vuetify', + type: 'content', + component: (props, runtime) => FormSectionTitle, + }), + FormSectionGroupContainer: createComponentSpec({ + id: 'FormSectionGroupContainer-vuetify', + type: 'container', + component: (props, runtime) => FormSectionGroupContainer, + }), + FormSectionGroup: createComponentSpec({ + id: 'FormSectionGroup-vuetify', + type: 'container', + component: (props, runtime) => FormSectionGroup, + }), +}; diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormContainer.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormContainer.ts new file mode 100644 index 0000000..83730c3 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormContainer.ts @@ -0,0 +1,39 @@ +/** + * Vuetify FormContainer Component + */ + +import { defineComponent, h } from 'vue'; +import { VForm } from 'vuetify/components'; +import { useScheptaFormAdapter } from '@schepta/factory-vue'; +import { SubmitButton } from '../SubmitButton'; + +export const FormContainer = defineComponent({ + name: 'VuetifyFormContainer', + props: { + onSubmit: Function, + }, + setup(props, { slots }) { + const adapter = useScheptaFormAdapter(); + + const handleFormSubmit = (e: Event) => { + e.preventDefault(); + if (props.onSubmit) { + adapter.handleSubmit(props.onSubmit)(); + } + }; + + return () => { + return h( + VForm, + { + 'data-test-id': 'FormContainer', + onSubmit: handleFormSubmit, + }, + () => [ + slots.default?.(), + props.onSubmit && h(SubmitButton, { onSubmit: props.onSubmit }), + ] + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormField.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormField.ts new file mode 100644 index 0000000..1a8d586 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormField.ts @@ -0,0 +1,22 @@ +/** + * Vuetify FormField Container + */ + +import { defineComponent, h } from 'vue'; + +export const FormField = defineComponent({ + name: 'VuetifyFormField', + setup(props, { slots }) { + return () => { + return h( + 'div', + { + style: { + marginBottom: '16px', + }, + }, + slots.default?.() + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionContainer.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionContainer.ts new file mode 100644 index 0000000..43c6d1c --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionContainer.ts @@ -0,0 +1,22 @@ +/** + * Vuetify FormSectionContainer Component + */ + +import { defineComponent, h } from 'vue'; +import { VCard, VCardText } from 'vuetify/components'; + +export const FormSectionContainer = defineComponent({ + name: 'VuetifyFormSectionContainer', + setup(props, { slots }) { + return () => { + return h( + VCard, + { + variant: 'outlined', + style: { marginBottom: '24px' }, + }, + () => h(VCardText, {}, slots.default) + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroup.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroup.ts new file mode 100644 index 0000000..edc3d71 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroup.ts @@ -0,0 +1,24 @@ +/** + * Vuetify FormSectionGroup Component + */ + +import { defineComponent, h } from 'vue'; + +export const FormSectionGroup = defineComponent({ + name: 'VuetifyFormSectionGroup', + setup(props, { slots }) { + return () => { + return h( + 'div', + { + style: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + }, + slots.default?.() + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroupContainer.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroupContainer.ts new file mode 100644 index 0000000..27c4b0e --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionGroupContainer.ts @@ -0,0 +1,25 @@ +/** + * Vuetify FormSectionGroupContainer Component + */ + +import { defineComponent, h } from 'vue'; + +export const FormSectionGroupContainer = defineComponent({ + name: 'VuetifyFormSectionGroupContainer', + setup(props, { slots }) { + return () => { + return h( + 'div', + { + style: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', + gap: '16px', + marginBottom: '16px', + }, + }, + slots.default?.() + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionTitle.ts b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionTitle.ts new file mode 100644 index 0000000..7119d69 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Containers/FormSectionTitle.ts @@ -0,0 +1,28 @@ +/** + * Vuetify FormSectionTitle Component + */ + +import { defineComponent, h } from 'vue'; + +export const FormSectionTitle = defineComponent({ + name: 'VuetifyFormSectionTitle', + props: { + title: String, + }, + setup(props) { + return () => { + return h( + 'h3', + { + style: { + fontSize: '1.25rem', + fontWeight: '500', + marginBottom: '16px', + color: '#1976d2', + }, + }, + props.title + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Form.vue b/showcases/src/frameworks/vue/vuetify/components/Form.vue new file mode 100644 index 0000000..ad39309 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Form.vue @@ -0,0 +1,32 @@ + + + diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputCheckbox.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputCheckbox.ts new file mode 100644 index 0000000..0593388 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputCheckbox.ts @@ -0,0 +1,42 @@ +/** + * Vuetify InputCheckbox Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VCheckbox } from 'vuetify/components'; + +export const InputCheckbox = defineComponent({ + name: 'VuetifyInputCheckbox', + props: { + name: { type: String, required: true }, + value: { type: [Boolean, String] as PropType, default: false }, + onChange: { type: Function as PropType<(v: boolean) => void> }, + label: String, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const checked = props.value === true || props.value === 'true'; + return h(VCheckbox, { + modelValue: checked, + name: props.name, + label: props.label, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: boolean) => { + props.onChange?.(val); + emit('update:modelValue', val); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputDate.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputDate.ts new file mode 100644 index 0000000..203879b --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputDate.ts @@ -0,0 +1,46 @@ +/** + * Vuetify InputDate Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VTextField } from 'vuetify/components'; + +export const InputDate = defineComponent({ + name: 'VuetifyInputDate', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h(VTextField, { + modelValue: String(value), + name: props.name, + label: props.label, + placeholder: props.placeholder, + type: 'date', + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + variant: 'outlined', + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: string) => { + props.onChange?.(val); + emit('update:modelValue', val); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputNumber.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputNumber.ts new file mode 100644 index 0000000..b21c909 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputNumber.ts @@ -0,0 +1,53 @@ +/** + * Vuetify InputNumber Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VTextField } from 'vuetify/components'; + +export const InputNumber = defineComponent({ + name: 'VuetifyInputNumber', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: number | string) => void> }, + label: String, + placeholder: String, + min: Number, + max: Number, + step: [Number, String] as PropType, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h(VTextField, { + modelValue: String(value), + name: props.name, + label: props.label, + placeholder: props.placeholder, + type: 'number', + min: props.min, + max: props.max, + step: props.step, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + variant: 'outlined', + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: string) => { + const numVal = val ? Number(val) : ''; + props.onChange?.(numVal); + emit('update:modelValue', numVal); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputSelect.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputSelect.ts new file mode 100644 index 0000000..2629455 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputSelect.ts @@ -0,0 +1,57 @@ +/** + * Vuetify InputSelect Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VSelect } from 'vuetify/components'; + +export interface InputSelectOption { + value: string; + label: string; +} + +export const InputSelect = defineComponent({ + name: 'VuetifyInputSelect', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + options: { type: Array as PropType, default: () => [] }, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + const items = (props.options || []).map(opt => ({ + title: opt.label, + value: opt.value, + })); + + return h(VSelect, { + modelValue: String(value), + name: props.name, + label: props.label, + placeholder: props.placeholder, + items, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + variant: 'outlined', + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: string) => { + props.onChange?.(val); + emit('update:modelValue', val); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputText.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputText.ts new file mode 100644 index 0000000..cb935e6 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputText.ts @@ -0,0 +1,47 @@ +/** + * Vuetify InputText Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VTextField } from 'vuetify/components'; + +export const InputText = defineComponent({ + name: 'VuetifyInputText', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + 'data-test-id': String, + type: { type: String, default: 'text' }, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h(VTextField, { + modelValue: String(value), + name: props.name, + label: props.label, + placeholder: props.placeholder, + type: props.type, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + variant: 'outlined', + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: string) => { + props.onChange?.(val); + emit('update:modelValue', val); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/Inputs/InputTextarea.ts b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputTextarea.ts new file mode 100644 index 0000000..84cf9ab --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/Inputs/InputTextarea.ts @@ -0,0 +1,47 @@ +/** + * Vuetify InputTextarea Component + */ + +import { defineComponent, h, type PropType } from 'vue'; +import { VTextarea } from 'vuetify/components'; + +export const InputTextarea = defineComponent({ + name: 'VuetifyInputTextarea', + props: { + name: { type: String, required: true }, + value: { type: [String, Number] as PropType, default: '' }, + onChange: { type: Function as PropType<(v: string) => void> }, + label: String, + placeholder: String, + rows: { type: Number, default: 4 }, + 'data-test-id': String, + required: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + externalContext: Object, + 'x-component-props': Object, + 'x-ui': Object, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => { + const value = props.value ?? ''; + return h(VTextarea, { + modelValue: String(value), + name: props.name, + label: props.label, + placeholder: props.placeholder, + rows: props.rows, + required: props.required, + disabled: props.disabled, + 'data-test-id': props['data-test-id'] ?? props.name, + variant: 'outlined', + density: 'comfortable', + ...props['x-component-props'], + 'onUpdate:modelValue': (val: string) => { + props.onChange?.(val); + emit('update:modelValue', val); + }, + }); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/components/SubmitButton.ts b/showcases/src/frameworks/vue/vuetify/components/SubmitButton.ts new file mode 100644 index 0000000..5a192c8 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/components/SubmitButton.ts @@ -0,0 +1,28 @@ +/** + * Vuetify SubmitButton Component + */ + +import { defineComponent, h } from 'vue'; +import { VBtn } from 'vuetify/components'; + +export const SubmitButton = defineComponent({ + name: 'VuetifySubmitButton', + props: { + onSubmit: Function, + label: { type: String, default: 'Submit' }, + }, + setup(props) { + return () => { + return h( + VBtn, + { + type: 'submit', + color: 'primary', + 'data-test-id': 'submit-button', + style: { marginTop: '16px' }, + }, + () => props.label + ); + }; + }, +}); diff --git a/showcases/src/frameworks/vue/vuetify/pages/VuetifyFormPage.vue b/showcases/src/frameworks/vue/vuetify/pages/VuetifyFormPage.vue new file mode 100644 index 0000000..00b3cd4 --- /dev/null +++ b/showcases/src/frameworks/vue/vuetify/pages/VuetifyFormPage.vue @@ -0,0 +1,37 @@ + + + diff --git a/tests/e2e/vue.spec.ts b/tests/e2e/vue.spec.ts new file mode 100644 index 0000000..83026f4 --- /dev/null +++ b/tests/e2e/vue.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from '@playwright/test'; +import simpleFormSchema from '../../instances/form/simple-form.json'; +import complexFormSchema from '../../instances/form/complex-form.json'; +import { extractFieldsFromSchema, FormSchema } from '@schepta/core'; + +test.describe('Vue Form Factory', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(`${baseURL}vue`); + }); + + test('should render simple form', async ({ page }) => { + const fields = extractFieldsFromSchema(simpleFormSchema as FormSchema); + + await page.waitForSelector('input[data-test-id*="firstName"]', { timeout: 10000 }); + + for (const field of fields) { + await expect(page.locator(`input[data-test-id*="${field.name}"]`)).toBeVisible(); + } + }); + + test('should render complex form with all field types', async ({ page }) => { + await page.click('[data-test-id*="complex-form-tab"]'); + + const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter( + (field) => field.visible === true && field.custom === false + ); + + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + for (const field of fields) { + if (field.component === 'InputSelect') { + await expect(page.locator(`select[data-test-id*="${field.name}"]`).first()).toBeVisible(); + } else if (field.component === 'InputTextarea') { + await expect(page.locator(`textarea[data-test-id*="${field.name}"]`).first()).toBeVisible(); + } else { + await expect(page.locator(`input[data-test-id*="${field.name}"]`).first()).toBeVisible(); + } + } + }); + + test('should fill form fields', async ({ page }) => { + const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter( + (field) => + field.props.disabled !== true && field.visible === true && field.custom === false + ); + + const inputValues = { + email: 'john.doe@example.com', + phone: '(123) 456-7890', + firstName: 'John', + lastName: 'Doe', + userType: 'individual', + birthDate: '1990-01-01', + maritalStatus: 'single', + bio: 'I am a software engineer', + acceptTerms: true, + }; + + await page.click('[data-test-id*="complex-form-tab"]'); + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + for (const field of fields) { + const value = inputValues[field.name as keyof typeof inputValues]; + if ( + field.component === 'InputText' || + field.component === 'InputPhone' || + field.component === 'InputDate' + ) { + await page.locator(`input[data-test-id*="${field.name}"]`).first().fill(String(value)); + } else if (field.component === 'InputTextarea') { + await page.locator(`textarea[data-test-id*="${field.name}"]`).first().fill(String(value)); + } else if (field.component === 'InputSelect') { + await page.locator(`select[data-test-id*="${field.name}"]`).first().selectOption(String(value)); + } else if (field.component === 'InputCheckbox') { + await page.locator(`input[data-test-id*="${field.name}"]`).first().check(); + } + } + + for (const field of fields) { + const value = inputValues[field.name as keyof typeof inputValues]; + if (field.component === 'InputCheckbox') { + await expect(page.locator(`input[data-test-id*="${field.name}"]`).first()).toBeChecked(); + } else if (field.component === 'InputSelect') { + await expect(page.locator(`select[data-test-id*="${field.name}"]`).first()).toHaveValue( + String(value) + ); + } else if (field.component === 'InputTextarea') { + await expect(page.locator(`textarea[data-test-id*="${field.name}"]`).first()).toHaveValue( + String(value) + ); + } else { + await expect(page.locator(`input[data-test-id*="${field.name}"]`).first()).toHaveValue( + String(value) + ); + } + } + }); + + test('should validate disabled fields', async ({ page }) => { + const disabledFields = extractFieldsFromSchema(complexFormSchema as FormSchema) + .filter((field) => field.props.disabled === true) + .map((field) => field.name); + + await page.click('[data-test-id*="complex-form-tab"]'); + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + for (const field of disabledFields) { + await expect(page.locator(`input[data-test-id*="${field}"]`).first()).toBeDisabled(); + } + }); + + test('should validate required fields', async ({ page }) => { + const requiredFields = extractFieldsFromSchema(complexFormSchema as FormSchema) + .filter((field) => field.props.required === true) + .map((field) => field.name); + + await page.click('[data-test-id*="complex-form-tab"]'); + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + for (const field of requiredFields) { + const fieldLocator = page.locator(`input[data-test-id*="${field}"]`).first(); + await expect(fieldLocator).toHaveAttribute('required', ''); + } + }); + + test('should show spouse name field when marital status is married', async ({ page }) => { + await page.click('[data-test-id*="complex-form-tab"]'); + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + await page.locator('select[data-test-id*="maritalStatus"]').first().selectOption('married'); + await expect(page.locator('input[data-test-id*="spouseName"]')).toBeVisible(); + }); + + test('should toggle social name custom field visibility', async ({ page }) => { + await page.click('[data-test-id*="complex-form-tab"]'); + await page.waitForSelector('input[data-test-id*="email"]', { timeout: 10000 }); + + const toggleButton = page.locator('button[data-test-id="social-name-toggle"]'); + const socialNameInput = page.locator('input[data-test-id="social-name-input"]'); + + await expect(toggleButton).toBeVisible(); + await expect(toggleButton).toHaveText(/Add Social Name/i); + + await expect(socialNameInput).not.toBeVisible(); + + await toggleButton.click(); + + await expect(socialNameInput).toBeVisible(); + await expect(toggleButton).toHaveText(/Remove Social Name/i); + + await socialNameInput.fill('John Social'); + await expect(socialNameInput).toHaveValue('John Social'); + + await toggleButton.click(); + + await expect(socialNameInput).not.toBeVisible(); + await expect(toggleButton).toHaveText(/Add Social Name/i); + }); + + test('should submit form with valid data', async ({ page }) => { + await page.click('[data-test-id*="simple-form-tab"]'); + await page.waitForSelector('input[data-test-id*="firstName"]', { timeout: 10000 }); + + await page.locator('input[data-test-id*="firstName"]').fill('John'); + await page.locator('input[data-test-id*="lastName"]').fill('Doe'); + + await page.click('button[data-test-id="submit-button"]'); + + await page.waitForSelector('text=Submitted Values', { timeout: 5000 }); + + const submittedText = await page.textContent('pre'); + expect(submittedText).toContain('John'); + expect(submittedText).toContain('Doe'); + }); +}); diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 23848e4..2169fac 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -16,12 +16,18 @@ export default defineConfig({ projects: [ { name: 'react', - use: { + use: { ...devices['Desktop Chrome'], - // baseURL: 'https://show.schepta.org/', baseURL: 'http://localhost:3000/', }, - } - ] + }, + { + name: 'vue', + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://localhost:3000/', + }, + }, + ], }); diff --git a/vue-complex-form-list.png b/vue-complex-form-list.png new file mode 100644 index 0000000..952ed9a Binary files /dev/null and b/vue-complex-form-list.png differ diff --git a/vue-complex-form-updated.png b/vue-complex-form-updated.png new file mode 100644 index 0000000..7f2e1f1 Binary files /dev/null and b/vue-complex-form-updated.png differ