diff --git a/.gitignore b/.gitignore index ce0a6c1..196a410 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,16 @@ docs/CODESANDBOX_TEMPLATES.md .env.local .env.*.local +# Playwright test artifacts test-results/ +test-screenshots/ tests/test-results/ tests/playwright-report/ +playwright-report/ + +# Screenshots (ignore all images in root) +/*.png +/*.jpg +/*.jpeg +/*.gif +/*.webp diff --git a/packages/adapters/vanilla/src/form-adapter.ts b/packages/adapters/vanilla/src/form-adapter.ts index a4d75d8..40ca50c 100644 --- a/packages/adapters/vanilla/src/form-adapter.ts +++ b/packages/adapters/vanilla/src/form-adapter.ts @@ -29,12 +29,43 @@ export class VanillaFormAdapter implements FormAdapter { } getValue(field: string): any { + // Support nested paths (e.g., "user.name") + if (field.includes('.')) { + const parts = field.split('.'); + let value: any = this.values; + for (const part of parts) { + if (value === undefined || value === null) return undefined; + value = value[part]; + } + return value; + } return this.values[field]; } setValue(field: string, value: any): void { - const oldValue = this.values[field]; - this.values[field] = value; + const oldValue = this.getValue(field); + + // Support nested paths (e.g., "user.name") + if (field.includes('.')) { + const parts = field.split('.'); + let current: any = this.values; + + // Create nested structure if needed + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part] || typeof current[part] !== 'object') { + current[part] = {}; + } + current = current[part]; + } + + // Set the final value + const lastPart = parts[parts.length - 1]; + current[lastPart] = value; + } else { + this.values[field] = value; + } + this.validateField(field); this.emitter.emit('change', { field, value, oldValue }); this.emitter.emit(`change:${field}`, { value, oldValue }); diff --git a/packages/adapters/vanilla/src/provider.ts b/packages/adapters/vanilla/src/provider.ts index c19d8d3..8185bd2 100644 --- a/packages/adapters/vanilla/src/provider.ts +++ b/packages/adapters/vanilla/src/provider.ts @@ -15,6 +15,7 @@ import { defaultDebugConfig } from '@schepta/core'; */ export interface ScheptaProviderProps { components?: Record; + customComponents?: Record; renderers?: Partial>; middlewares?: MiddlewareFn[]; debug?: DebugConfig; @@ -27,6 +28,7 @@ export interface ScheptaProviderProps { */ export interface ScheptaContextType { components: Record; + customComponents: Record; renderers: Record; middlewares: MiddlewareFn[]; debug: DebugConfig; @@ -62,6 +64,7 @@ export function createScheptaProvider( return { components: { ...parentContext.components, ...(props.components || {}) }, + customComponents: { ...parentContext.customComponents, ...(props.customComponents || {}) }, renderers: mergedRenderers, middlewares: [...parentContext.middlewares, ...(props.middlewares || [])], debug: { ...parentContext.debug, ...props.debug }, @@ -75,6 +78,7 @@ export function createScheptaProvider( return { components: props.components || {}, + customComponents: props.customComponents || {}, renderers: props.renderers, middlewares: props.middlewares || [], debug: mergedDebug, diff --git a/packages/adapters/vanilla/src/runtime-adapter.ts b/packages/adapters/vanilla/src/runtime-adapter.ts index 41a1e51..086bcb0 100644 --- a/packages/adapters/vanilla/src/runtime-adapter.ts +++ b/packages/adapters/vanilla/src/runtime-adapter.ts @@ -4,7 +4,7 @@ * Implements RuntimeAdapter using DOM APIs */ -import type { RuntimeAdapter, ComponentSpec, RenderResult } from '@schepta/core'; +import type { RuntimeAdapter, ComponentSpec, RendererSpec, RenderResult } from '@schepta/core'; import type { DOMElement } from './types'; @@ -12,7 +12,32 @@ import type { DOMElement } from './types'; * Vanilla JS runtime adapter implementation */ export class VanillaRuntimeAdapter implements RuntimeAdapter { - create(spec: ComponentSpec, props: Record): RenderResult { + create(spec: ComponentSpec | RendererSpec, props: Record): RenderResult { + if (!spec) { + console.error('[VanillaRuntime] Invalid spec:', spec); + throw new Error(`Invalid spec: spec is null/undefined`); + } + + // Handle RendererSpec (for field renderers, etc) + if ('renderer' in spec && spec.renderer && typeof spec.renderer === 'function') { + const result = spec.renderer(props); + if (result instanceof HTMLElement) { + return this.wrapElement(result, props); + } + if (result && 'element' in result) { + return result as DOMElement; + } + throw new Error(`Renderer ${spec.id} did not return a valid element`); + } + + // Handle ComponentSpec + if (!spec.component) { + console.error('[VanillaRuntime] Invalid spec:', spec); + throw new Error(`Invalid component spec: spec.id=${spec.id}, has component=${!!spec.component}`); + } + + // Call component function - it returns the element or factory + // Pass runtime adapter as second arg for compatibility with spec interface const component = spec.component(props, this); // If component returns a DOM element directly diff --git a/packages/factories/vanilla/src/components/DefaultFormContainer.ts b/packages/factories/vanilla/src/components/DefaultFormContainer.ts index ee1d9b0..241e7eb 100644 --- a/packages/factories/vanilla/src/components/DefaultFormContainer.ts +++ b/packages/factories/vanilla/src/components/DefaultFormContainer.ts @@ -13,7 +13,7 @@ import { createDefaultSubmitButton, type SubmitButtonFactory } from './DefaultSu */ export interface FormContainerProps { /** Child elements to render inside the form */ - children?: HTMLElement[]; + children?: any[]; /** Submit handler - when provided, renders a submit button */ onSubmit?: () => void; /** External context passed from FormFactory */ @@ -50,12 +50,23 @@ export function createDefaultFormContainer(props: FormContainerProps): HTMLEleme props.onSubmit?.(); }); - // Append children - props.children?.forEach(child => form.appendChild(child)); + // Append children - handle both HTMLElement and DOMElement wrapper + if (props.children) { + props.children.forEach(child => { + if (child && typeof child === 'object') { + if ('element' in child && child.element instanceof HTMLElement) { + form.appendChild(child.element); + } else if (child instanceof HTMLElement) { + form.appendChild(child); + } + } + }); + } // Add submit button if onSubmit is provided if (props.onSubmit) { - const submitButton = createDefaultSubmitButton({ onSubmit: props.onSubmit }); + const submitButtonFactory = props.createSubmitButton || createDefaultSubmitButton; + const submitButton = submitButtonFactory({ onSubmit: props.onSubmit }); form.appendChild(submitButton); } diff --git a/packages/factories/vanilla/src/components/DefaultSubmitButton.ts b/packages/factories/vanilla/src/components/DefaultSubmitButton.ts index 71fc311..5d8502b 100644 --- a/packages/factories/vanilla/src/components/DefaultSubmitButton.ts +++ b/packages/factories/vanilla/src/components/DefaultSubmitButton.ts @@ -28,7 +28,7 @@ export function createDefaultSubmitButton(props: SubmitButtonProps): HTMLElement container.style.textAlign = 'right'; const button = document.createElement('button'); - button.type = 'button'; + button.type = 'submit'; button.textContent = 'Submit'; button.dataset.testId = 'submit-button'; button.style.padding = '12px 24px'; @@ -39,7 +39,6 @@ export function createDefaultSubmitButton(props: SubmitButtonProps): HTMLElement button.style.cursor = 'pointer'; button.style.fontSize = '16px'; button.style.fontWeight = '500'; - button.addEventListener('click', props.onSubmit); container.appendChild(button); return container; diff --git a/packages/factories/vanilla/src/components/FormField.ts b/packages/factories/vanilla/src/components/FormField.ts new file mode 100644 index 0000000..2cf7bca --- /dev/null +++ b/packages/factories/vanilla/src/components/FormField.ts @@ -0,0 +1,27 @@ +/** + * Vanilla FormField Component + */ + +export interface FormFieldProps { + children?: any[]; +} + +export function createFormField(props: FormFieldProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + // Render children + if (props.children) { + props.children.forEach(child => { + if (child && typeof child === 'object') { + if ('element' in child && child.element instanceof HTMLElement) { + wrapper.appendChild(child.element); + } else if (child instanceof HTMLElement) { + wrapper.appendChild(child); + } + } + }); + } + + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/FormSectionContainer.ts b/packages/factories/vanilla/src/components/FormSectionContainer.ts new file mode 100644 index 0000000..2c9782e --- /dev/null +++ b/packages/factories/vanilla/src/components/FormSectionContainer.ts @@ -0,0 +1,31 @@ +/** + * Vanilla FormSectionContainer Component + */ + +export interface FormSectionContainerProps { + children?: any[]; +} + +export function createFormSectionContainer(props: FormSectionContainerProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '24px'; + wrapper.style.padding = '20px'; + wrapper.style.border = '1px solid #e5e7eb'; + wrapper.style.borderRadius = '8px'; + wrapper.style.background = '#fff'; + + // Render children + if (props.children) { + props.children.forEach(child => { + if (child && typeof child === 'object') { + if ('element' in child && child.element instanceof HTMLElement) { + wrapper.appendChild(child.element); + } else if (child instanceof HTMLElement) { + wrapper.appendChild(child); + } + } + }); + } + + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/FormSectionGroup.ts b/packages/factories/vanilla/src/components/FormSectionGroup.ts new file mode 100644 index 0000000..8067e71 --- /dev/null +++ b/packages/factories/vanilla/src/components/FormSectionGroup.ts @@ -0,0 +1,29 @@ +/** + * Vanilla FormSectionGroup Component + */ + +export interface FormSectionGroupProps { + children?: any[]; +} + +export function createFormSectionGroup(props: FormSectionGroupProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.display = 'flex'; + wrapper.style.flexDirection = 'column'; + wrapper.style.gap = '8px'; + + // Render children + if (props.children) { + props.children.forEach(child => { + if (child && typeof child === 'object') { + if ('element' in child && child.element instanceof HTMLElement) { + wrapper.appendChild(child.element); + } else if (child instanceof HTMLElement) { + wrapper.appendChild(child); + } + } + }); + } + + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/FormSectionGroupContainer.ts b/packages/factories/vanilla/src/components/FormSectionGroupContainer.ts new file mode 100644 index 0000000..a6f0c99 --- /dev/null +++ b/packages/factories/vanilla/src/components/FormSectionGroupContainer.ts @@ -0,0 +1,30 @@ +/** + * Vanilla FormSectionGroupContainer Component + */ + +export interface FormSectionGroupContainerProps { + children?: any[]; +} + +export function createFormSectionGroupContainer(props: FormSectionGroupContainerProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.display = 'grid'; + wrapper.style.gridTemplateColumns = 'repeat(2, 1fr)'; + wrapper.style.gap = '16px'; + wrapper.style.marginBottom = '16px'; + + // Render children + if (props.children) { + props.children.forEach(child => { + if (child && typeof child === 'object') { + if ('element' in child && child.element instanceof HTMLElement) { + wrapper.appendChild(child.element); + } else if (child instanceof HTMLElement) { + wrapper.appendChild(child); + } + } + }); + } + + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/FormSectionTitle.ts b/packages/factories/vanilla/src/components/FormSectionTitle.ts new file mode 100644 index 0000000..c24ebf1 --- /dev/null +++ b/packages/factories/vanilla/src/components/FormSectionTitle.ts @@ -0,0 +1,20 @@ +/** + * Vanilla FormSectionTitle Component + */ + +export interface FormSectionTitleProps { + title?: string; + children?: any[]; +} + +export function createFormSectionTitle(props: FormSectionTitleProps): HTMLElement { + const heading = document.createElement('h3'); + heading.textContent = props.title || ''; + heading.style.fontSize = '1.25rem'; + heading.style.fontWeight = '600'; + heading.style.marginTop = '0'; + heading.style.marginBottom = '16px'; + heading.style.color = '#111827'; + + return heading; +} diff --git a/packages/factories/vanilla/src/components/InputAutocomplete.ts b/packages/factories/vanilla/src/components/InputAutocomplete.ts new file mode 100644 index 0000000..b475c02 --- /dev/null +++ b/packages/factories/vanilla/src/components/InputAutocomplete.ts @@ -0,0 +1,82 @@ +/** + * Vanilla InputAutocomplete Component + */ + +export interface InputAutocompleteOption { + value: string; + label?: string; +} + +export interface InputAutocompleteProps { + name: string; + value?: string; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + options?: InputAutocompleteOption[] | string[]; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +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 function createInputAutocomplete(props: InputAutocompleteProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + const listId = `${props.name}-datalist`; + const normalizedOptions = normalizeOptions(props.options || []); + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const input = document.createElement('input'); + input.id = props.name; + input.name = props.name; + input.value = props.value || ''; + input.placeholder = props.placeholder || ''; + input.required = props.required || false; + input.disabled = props.disabled || false; + input.setAttribute('data-test-id', props['data-test-id'] || props.name); + input.setAttribute('list', listId); + + input.style.width = '100%'; + input.style.padding = '8px'; + input.style.border = '1px solid #ccc'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + props.onChange?.((e.target as HTMLInputElement).value); + }); + + const datalist = document.createElement('datalist'); + datalist.id = listId; + + normalizedOptions.forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + datalist.appendChild(option); + }); + + wrapper.appendChild(input); + wrapper.appendChild(datalist); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputCheckbox.ts b/packages/factories/vanilla/src/components/InputCheckbox.ts new file mode 100644 index 0000000..0d6bd4e --- /dev/null +++ b/packages/factories/vanilla/src/components/InputCheckbox.ts @@ -0,0 +1,46 @@ +/** + * Vanilla InputCheckbox Component + */ + +export interface InputCheckboxProps { + name: string; + value?: boolean | string; + onChange?: (value: boolean) => void; + label?: string; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputCheckbox(props: InputCheckboxProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + const labelContainer = document.createElement('label'); + labelContainer.style.display = 'flex'; + labelContainer.style.alignItems = 'center'; + labelContainer.style.gap = '8px'; + labelContainer.style.cursor = 'pointer'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.name = props.name; + checkbox.checked = props.value === true || props.value === 'true'; + checkbox.required = props.required || false; + checkbox.disabled = props.disabled || false; + checkbox.setAttribute('data-test-id', props['data-test-id'] || props.name); + + checkbox.addEventListener('change', (e) => { + props.onChange?.((e.target as HTMLInputElement).checked); + }); + + labelContainer.appendChild(checkbox); + + if (props.label) { + const labelText = document.createTextNode(props.label); + labelContainer.appendChild(labelText); + } + + wrapper.appendChild(labelContainer); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputDate.ts b/packages/factories/vanilla/src/components/InputDate.ts new file mode 100644 index 0000000..2a0f83a --- /dev/null +++ b/packages/factories/vanilla/src/components/InputDate.ts @@ -0,0 +1,52 @@ +/** + * Vanilla InputDate Component + */ + +export interface InputDateProps { + name: string; + value?: string; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputDate(props: InputDateProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const input = document.createElement('input'); + input.type = 'date'; + input.id = props.name; + input.name = props.name; + input.value = props.value || ''; + input.placeholder = props.placeholder || ''; + input.required = props.required || false; + input.disabled = props.disabled || false; + input.setAttribute('data-test-id', props['data-test-id'] || props.name); + + input.style.width = '100%'; + input.style.padding = '8px'; + input.style.border = '1px solid #ccc'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + props.onChange?.((e.target as HTMLInputElement).value); + }); + + wrapper.appendChild(input); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputNumber.ts b/packages/factories/vanilla/src/components/InputNumber.ts new file mode 100644 index 0000000..585820b --- /dev/null +++ b/packages/factories/vanilla/src/components/InputNumber.ts @@ -0,0 +1,61 @@ +/** + * Vanilla InputNumber Component + */ + +export interface InputNumberProps { + name: string; + value?: string | number; + onChange?: (value: number | string) => void; + label?: string; + placeholder?: string; + min?: number; + max?: number; + step?: number | string; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputNumber(props: InputNumberProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const input = document.createElement('input'); + input.type = 'number'; + input.id = props.name; + input.name = props.name; + input.value = props.value?.toString() || ''; + input.placeholder = props.placeholder || ''; + input.required = props.required || false; + input.disabled = props.disabled || false; + input.setAttribute('data-test-id', props['data-test-id'] || props.name); + + if (props.min !== undefined) input.min = props.min.toString(); + if (props.max !== undefined) input.max = props.max.toString(); + if (props.step !== undefined) input.step = props.step.toString(); + + input.style.width = '100%'; + input.style.padding = '8px'; + input.style.border = '1px solid #ccc'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + const raw = (e.target as HTMLInputElement).value; + const val = raw ? Number(raw) : ''; + props.onChange?.(val); + }); + + wrapper.appendChild(input); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputPhone.ts b/packages/factories/vanilla/src/components/InputPhone.ts new file mode 100644 index 0000000..9e54441 --- /dev/null +++ b/packages/factories/vanilla/src/components/InputPhone.ts @@ -0,0 +1,52 @@ +/** + * Vanilla InputPhone Component + */ + +export interface InputPhoneProps { + name: string; + value?: string; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputPhone(props: InputPhoneProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const input = document.createElement('input'); + input.type = 'tel'; + input.id = props.name; + input.name = props.name; + input.value = props.value || ''; + input.placeholder = props.placeholder || ''; + input.required = props.required || false; + input.disabled = props.disabled || false; + input.setAttribute('data-test-id', props['data-test-id'] || props.name); + + input.style.width = '100%'; + input.style.padding = '8px'; + input.style.border = '1px solid #ccc'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + props.onChange?.((e.target as HTMLInputElement).value); + }); + + wrapper.appendChild(input); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputSelect.ts b/packages/factories/vanilla/src/components/InputSelect.ts new file mode 100644 index 0000000..b6bc248 --- /dev/null +++ b/packages/factories/vanilla/src/components/InputSelect.ts @@ -0,0 +1,73 @@ +/** + * Vanilla InputSelect Component + */ + +export interface InputSelectOption { + value: string; + label: string; +} + +export interface InputSelectProps { + name: string; + value?: string | number; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + options?: InputSelectOption[]; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputSelect(props: InputSelectProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const select = document.createElement('select'); + select.id = props.name; + select.name = props.name; + select.value = props.value?.toString() || ''; + select.required = props.required || false; + select.disabled = props.disabled || false; + select.setAttribute('data-test-id', props['data-test-id'] || props.name); + + select.style.width = '100%'; + select.style.padding = '8px'; + select.style.border = '1px solid #ccc'; + select.style.borderRadius = '4px'; + select.style.fontSize = '14px'; + + // Add placeholder option + const placeholderOption = document.createElement('option'); + placeholderOption.value = ''; + placeholderOption.textContent = props.placeholder || 'Select...'; + select.appendChild(placeholderOption); + + // Add options + (props.options || []).forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === props.value) { + option.selected = true; + } + select.appendChild(option); + }); + + select.addEventListener('change', (e) => { + props.onChange?.((e.target as HTMLSelectElement).value); + }); + + wrapper.appendChild(select); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputText.ts b/packages/factories/vanilla/src/components/InputText.ts new file mode 100644 index 0000000..b343dfa --- /dev/null +++ b/packages/factories/vanilla/src/components/InputText.ts @@ -0,0 +1,53 @@ +/** + * Vanilla InputText Component + */ + +export interface InputTextProps { + name: string; + value?: string | number; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + type?: string; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputText(props: InputTextProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const input = document.createElement('input'); + input.type = props.type || 'text'; + input.id = props.name; + input.name = props.name; + input.value = props.value?.toString() || ''; + input.placeholder = props.placeholder || ''; + input.required = props.required || false; + input.disabled = props.disabled || false; + input.setAttribute('data-test-id', props['data-test-id'] || props.name); + + input.style.width = '100%'; + input.style.padding = '8px'; + input.style.border = '1px solid #ccc'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + props.onChange?.((e.target as HTMLInputElement).value); + }); + + wrapper.appendChild(input); + return wrapper; +} diff --git a/packages/factories/vanilla/src/components/InputTextarea.ts b/packages/factories/vanilla/src/components/InputTextarea.ts new file mode 100644 index 0000000..be84df0 --- /dev/null +++ b/packages/factories/vanilla/src/components/InputTextarea.ts @@ -0,0 +1,55 @@ +/** + * Vanilla InputTextarea Component + */ + +export interface InputTextareaProps { + name: string; + value?: string; + onChange?: (value: string) => void; + label?: string; + placeholder?: string; + rows?: number; + required?: boolean; + disabled?: boolean; + 'data-test-id'?: string; +} + +export function createInputTextarea(props: InputTextareaProps): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + + if (props.label) { + const label = document.createElement('label'); + label.textContent = props.label; + label.htmlFor = props.name; + label.style.display = 'block'; + label.style.marginBottom = '4px'; + label.style.fontWeight = '500'; + wrapper.appendChild(label); + } + + const textarea = document.createElement('textarea'); + textarea.id = props.name; + textarea.name = props.name; + textarea.value = props.value || ''; + textarea.placeholder = props.placeholder || ''; + textarea.rows = props.rows || 4; + textarea.required = props.required || false; + textarea.disabled = props.disabled || false; + textarea.setAttribute('data-test-id', props['data-test-id'] || props.name); + + textarea.style.width = '100%'; + textarea.style.padding = '8px'; + textarea.style.border = '1px solid #ccc'; + textarea.style.borderRadius = '4px'; + textarea.style.fontSize = '14px'; + textarea.style.fontFamily = 'inherit'; + textarea.style.resize = 'vertical'; + + textarea.addEventListener('input', (e) => { + props.onChange?.((e.target as HTMLTextAreaElement).value); + }); + + wrapper.appendChild(textarea); + return wrapper; +} diff --git a/packages/factories/vanilla/src/defaults/register-default-components.ts b/packages/factories/vanilla/src/defaults/register-default-components.ts new file mode 100644 index 0000000..4cb0b31 --- /dev/null +++ b/packages/factories/vanilla/src/defaults/register-default-components.ts @@ -0,0 +1,103 @@ +/** + * Register default components for Vanilla factory + */ + +import { setFactoryDefaultComponents, createComponentSpec } from '@schepta/core'; +import { createDefaultFormContainer } from '../components/DefaultFormContainer'; +import { createDefaultSubmitButton } from '../components/DefaultSubmitButton'; +import { createFormField } from '../components/FormField'; +import { createFormSectionContainer } from '../components/FormSectionContainer'; +import { createFormSectionTitle } from '../components/FormSectionTitle'; +import { createFormSectionGroup } from '../components/FormSectionGroup'; +import { createFormSectionGroupContainer } from '../components/FormSectionGroupContainer'; +import { createInputText } from '../components/InputText'; +import { createInputSelect } from '../components/InputSelect'; +import { createInputCheckbox } from '../components/InputCheckbox'; +import { createInputDate } from '../components/InputDate'; +import { createInputPhone } from '../components/InputPhone'; +import { createInputNumber } from '../components/InputNumber'; +import { createInputTextarea } from '../components/InputTextarea'; +import { createInputAutocomplete } from '../components/InputAutocomplete'; + +/** + * Register all default Vanilla components with the core registry + */ +export function registerDefaultComponents() { + setFactoryDefaultComponents({ + FormContainer: createComponentSpec({ + id: 'FormContainer-vanilla', + type: 'container', + component: () => createDefaultFormContainer, + }), + SubmitButton: createComponentSpec({ + id: 'SubmitButton-vanilla', + type: 'content', + component: () => createDefaultSubmitButton, + }), + FormField: createComponentSpec({ + id: 'FormField-vanilla', + type: 'container', + component: () => createFormField, + }), + FormSectionContainer: createComponentSpec({ + id: 'FormSectionContainer-vanilla', + type: 'container', + component: () => createFormSectionContainer, + }), + FormSectionTitle: createComponentSpec({ + id: 'FormSectionTitle-vanilla', + type: 'content', + component: () => createFormSectionTitle, + }), + FormSectionGroup: createComponentSpec({ + id: 'FormSectionGroup-vanilla', + type: 'container', + component: () => createFormSectionGroup, + }), + FormSectionGroupContainer: createComponentSpec({ + id: 'FormSectionGroupContainer-vanilla', + type: 'container', + component: () => createFormSectionGroupContainer, + }), + InputText: createComponentSpec({ + id: 'InputText-vanilla', + type: 'field', + component: () => createInputText, + }), + InputSelect: createComponentSpec({ + id: 'InputSelect-vanilla', + type: 'field', + component: () => createInputSelect, + }), + InputCheckbox: createComponentSpec({ + id: 'InputCheckbox-vanilla', + type: 'field', + component: () => createInputCheckbox, + }), + InputDate: createComponentSpec({ + id: 'InputDate-vanilla', + type: 'field', + component: () => createInputDate, + }), + InputPhone: createComponentSpec({ + id: 'InputPhone-vanilla', + type: 'field', + component: () => createInputPhone, + }), + InputNumber: createComponentSpec({ + id: 'InputNumber-vanilla', + type: 'field', + component: () => createInputNumber, + }), + InputTextarea: createComponentSpec({ + id: 'InputTextarea-vanilla', + type: 'field', + component: () => createInputTextarea, + }), + InputAutocomplete: createComponentSpec({ + id: 'InputAutocomplete-vanilla', + type: 'field', + component: () => createInputAutocomplete, + }), + }); +} diff --git a/packages/factories/vanilla/src/defaults/register-default-renderers.ts b/packages/factories/vanilla/src/defaults/register-default-renderers.ts new file mode 100644 index 0000000..631fd2f --- /dev/null +++ b/packages/factories/vanilla/src/defaults/register-default-renderers.ts @@ -0,0 +1,26 @@ +/** + * Register default renderers for Vanilla factory + * + * Note: Unlike React/Vue, Vanilla renderers need the FormAdapter instance + * which is only available inside createFormFactory. Therefore, we export + * a function that creates the renderers with the adapter. + */ + +import { createRendererSpec } from '@schepta/core'; +import type { FormAdapter } from '@schepta/core'; +import { createDefaultFieldRenderer } from '../renderers/DefaultFieldRenderer'; + +/** + * Create default renderers with form adapter binding + * + * @param adapter Form adapter instance for field binding + */ +export function createDefaultRenderers(adapter: FormAdapter) { + return { + field: createRendererSpec({ + id: 'field-renderer-vanilla', + type: 'field', + component: () => createDefaultFieldRenderer(adapter) as any, + }), + }; +} diff --git a/packages/factories/vanilla/src/form-factory.ts b/packages/factories/vanilla/src/form-factory.ts index b5fad39..5c871c8 100644 --- a/packages/factories/vanilla/src/form-factory.ts +++ b/packages/factories/vanilla/src/form-factory.ts @@ -2,40 +2,29 @@ * Vanilla JS Form Factory */ -import type { FormSchema, ComponentSpec, FactorySetupResult, FormAdapter } from '@schepta/core'; +import type { FormSchema, ComponentSpec, FactorySetupResult, FormAdapter, MiddlewareFn } from '@schepta/core'; import { createVanillaRuntimeAdapter } from '@schepta/adapter-vanilla'; import { createVanillaFormAdapter } from '@schepta/adapter-vanilla'; import { getScheptaContext } from '@schepta/adapter-vanilla'; import { createComponentOrchestrator, - setFactoryDefaultComponents, - createComponentSpec, + createTemplateExpressionMiddleware, + createSchemaValidator, + formatValidationErrors, + getFactoryDefaultComponents, } from '@schepta/core'; import { buildInitialValues } from '@schepta/core'; +import formSchemaDefinition from '../../src/schemas/form-schema.json'; import { renderForm } from './form-renderer'; -import { - createDefaultFormContainer, - createDefaultSubmitButton -} from './components'; - -// Register factory default components (called once on module load) -setFactoryDefaultComponents({ - FormContainer: createComponentSpec({ - id: 'FormContainer', - type: 'container', - component: () => createDefaultFormContainer, - }), - SubmitButton: createComponentSpec({ - id: 'SubmitButton', - type: 'button', - component: () => createDefaultSubmitButton, - }), -}); +import { registerDefaultComponents } from './defaults/register-default-components'; +import { createDefaultRenderers } from './defaults/register-default-renderers'; export interface FormFactoryOptions { schema: FormSchema; components?: Record; + customComponents?: Record; renderers?: Partial>; + middlewares?: MiddlewareFn[]; externalContext?: Record; initialValues?: Record; onSubmit?: (values: Record) => void | Promise; @@ -60,47 +49,147 @@ export interface FormFactoryResult { } export function createFormFactory(options: FormFactoryOptions): FormFactoryResult { + // Register default components and renderers + registerDefaultComponents(); + + // Validate schema + let validation: { valid: boolean; errors?: any[] }; + try { + const validator = createSchemaValidator(formSchemaDefinition as object, { throwOnError: false }); + validation = validator(options.schema); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + validation = { + valid: false, + errors: [{ message: `Schema compilation error: ${msg}` }], + }; + } + + if (!validation.valid) { + console.error('Schema validation failed:', validation.errors); + const errorDiv = document.createElement('div'); + errorDiv.style.color = '#dc2626'; + errorDiv.style.padding = '16px'; + errorDiv.style.background = '#fee2e2'; + errorDiv.style.border = '1px solid #fecaca'; + errorDiv.style.borderRadius = '4px'; + errorDiv.style.marginBottom = '16px'; + errorDiv.innerHTML = ` +

Schema Validation Error

+
${formatValidationErrors(validation.errors || [])}
+ `; + options.container.appendChild(errorDiv); + + // Return empty form factory result + const emptyAdapter = createVanillaFormAdapter({}); + return { + formAdapter: emptyAdapter, + submit: () => {}, + reset: () => {}, + getValues: () => ({}), + destroy: () => { + options.container.innerHTML = ''; + }, + }; + } + + // Build initial values + const defaultValues = options.initialValues || buildInitialValues(options.schema); + + // Create form adapter + const formAdapter = createVanillaFormAdapter(defaultValues); + + // Create default renderers with adapter + const defaultRenderers = createDefaultRenderers(formAdapter); + // Get provider config (optional - returns null if no provider) const providerConfig = getScheptaContext(options.container); // Merge: local props > provider config > defaults - const mergedComponents = options.components || providerConfig?.components || {}; - const mergedRenderers = options.renderers || providerConfig?.renderers || {}; - const mergedMiddlewares = providerConfig?.middlewares || []; - const mergedExternalContext = { - ...(providerConfig?.externalContext || {}), - ...(options.externalContext || {}), + const mergedComponents = { + ...getFactoryDefaultComponents(), + ...(providerConfig?.components || {}), + ...(options.components || {}), + ...(options.customComponents || {}), + }; + const mergedRenderers = { + ...defaultRenderers, + ...(providerConfig?.renderers || {}), + ...(options.renderers || {}), }; + // Preserve getters/setters in externalContext using Object.defineProperties + const mergedExternalContext: Record = {}; + + // Copy provider context first + if (providerConfig?.externalContext) { + Object.keys(providerConfig.externalContext).forEach(key => { + const descriptor = Object.getOwnPropertyDescriptor(providerConfig.externalContext, key); + if (descriptor) { + Object.defineProperty(mergedExternalContext, key, descriptor); + } + }); + } + + // Then copy options context (overrides provider) + if (options.externalContext) { + Object.keys(options.externalContext).forEach(key => { + const descriptor = Object.getOwnPropertyDescriptor(options.externalContext, key); + if (descriptor) { + Object.defineProperty(mergedExternalContext, key, descriptor); + } + }); + } + + // Add onSubmit + if (options.onSubmit) { + mergedExternalContext.onSubmit = options.onSubmit; + } const mergedDebug = options.debug !== undefined ? options.debug : (providerConfig?.debug?.enabled || false); - const formAdapter = createVanillaFormAdapter( - options.initialValues || buildInitialValues(options.schema) - ); + // Create runtime adapter const runtime = createVanillaRuntimeAdapter(); const getFactorySetup = (): FactorySetupResult => { + const formValues = formAdapter.getValues(); + + // Debug context + const debugContext = mergedDebug ? { + isEnabled: true, + log: (category: string, message: string, data?: any) => { + console.log(`[${category}]`, message, data); + }, + buffer: { + add: () => {}, + clear: () => {}, + getAll: () => [], + }, + } : undefined; + + // Create middlewares with template expressions + const templateMiddleware = createTemplateExpressionMiddleware({ + formValues, + externalContext: mergedExternalContext, + debug: debugContext, + formAdapter, + }); + + const middlewares = [ + templateMiddleware, + ...(providerConfig?.middlewares || []), + ...(options.middlewares || []), + ]; + return { components: mergedComponents, + customComponents: options.customComponents, renderers: mergedRenderers, - externalContext: { - ...mergedExternalContext, - }, - state: formAdapter.getValues(), - middlewares: mergedMiddlewares, + externalContext: mergedExternalContext, + state: formValues, + middlewares, onSubmit: options.onSubmit ? () => formAdapter.handleSubmit(options.onSubmit!)() : undefined, - debug: mergedDebug ? { - isEnabled: true, - log: (category: string, message: string, data?: any) => { - console.log(`[${category}]`, message, data); - }, - buffer: { - add: () => {}, - clear: () => {}, - getAll: () => [], - }, - } : undefined, + debug: debugContext, formAdapter, }; }; diff --git a/packages/factories/vanilla/src/form-renderer.ts b/packages/factories/vanilla/src/form-renderer.ts index b0b0f88..a23f5e9 100644 --- a/packages/factories/vanilla/src/form-renderer.ts +++ b/packages/factories/vanilla/src/form-renderer.ts @@ -14,35 +14,22 @@ export interface FormRendererOptions { } export function renderForm(options: FormRendererOptions): DocumentFragment | DOMElement | null { - const result = options.renderer(options.componentKey, options.schema); - - if (!result) { - return null; - } - - // Handle children if schema has properties - if (options.schema.properties && typeof options.schema.properties === 'object') { - const fragment = document.createDocumentFragment(); + try { + const result = options.renderer(options.componentKey, options.schema); + if (!result) { + console.warn('[renderForm] Renderer returned null/undefined for key:', options.componentKey); + return null; + } + if (result && 'element' in result) { - const domElement = result as DOMElement; - fragment.appendChild(domElement.element); - - for (const [key, childSchema] of Object.entries(options.schema.properties)) { - const childResult = options.renderer(key, childSchema as any); - if (childResult && 'element' in childResult) { - fragment.appendChild((childResult as DOMElement).element); - } - } - - return fragment; + return result as DOMElement; } - } - if (result && 'element' in result) { - return result as DOMElement; + return null; + } catch (error) { + console.error('[renderForm] Error rendering component:', options.componentKey, error); + throw error; } - - return null; } diff --git a/packages/factories/vanilla/src/renderers/DefaultFieldRenderer.ts b/packages/factories/vanilla/src/renderers/DefaultFieldRenderer.ts new file mode 100644 index 0000000..1d95be8 --- /dev/null +++ b/packages/factories/vanilla/src/renderers/DefaultFieldRenderer.ts @@ -0,0 +1,82 @@ +/** + * Default Field Renderer for Vanilla + * + * Renders field components with native Schepta form adapter binding. + */ + +import type { FormAdapter } from '@schepta/core'; + +export interface FieldRendererProps { + name: string; + component: (props: any) => HTMLElement; + componentProps?: Record; + children?: any[]; +} + +/** + * Helper to get nested value from object using dot notation path + */ +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; +} + +/** + * Create default field renderer with form adapter binding + * + * This renderer: + * 1. Gets current value from adapter + * 2. Passes value and onChange handler to component + * 3. Watches for adapter value changes and updates DOM + * + * @param adapter Form adapter instance + * @returns Field renderer function + */ +export function createDefaultFieldRenderer( + adapter: FormAdapter +): (props: FieldRendererProps) => HTMLElement { + return (props: FieldRendererProps): HTMLElement => { + const { name, component, componentProps } = props; + + // Get current value from adapter + const currentValue = adapter.getValue(name); + + // Create component with binding + const element = component({ + ...componentProps, + name, + value: currentValue, + onChange: (value: any) => { + adapter.setValue(name, value); + }, + }); + + // Watch for value changes from adapter and update DOM + const reactiveState = adapter.watch(name); + reactiveState.watch((newValue) => { + // Find input/select/textarea element and update its value + const input = element.querySelector('input'); + const select = element.querySelector('select'); + const textarea = element.querySelector('textarea'); + + if (input) { + if (input.type === 'checkbox') { + input.checked = newValue === true || newValue === 'true'; + } else { + input.value = newValue?.toString() || ''; + } + } else if (select) { + select.value = newValue?.toString() || ''; + } else if (textarea) { + textarea.value = newValue?.toString() || ''; + } + }); + + return element; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da9b72..7277be5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: '@schepta/adapter-react': specifier: workspace:* version: link:../packages/adapters/react + '@schepta/adapter-vanilla': + specifier: workspace:* + version: link:../packages/adapters/vanilla '@schepta/adapter-vue': specifier: workspace:* version: link:../packages/adapters/vue @@ -319,6 +322,9 @@ importers: '@schepta/factory-react': specifier: workspace:* version: link:../packages/factories/react + '@schepta/factory-vanilla': + specifier: workspace:* + version: link:../packages/factories/vanilla '@schepta/factory-vue': specifier: workspace:* version: link:../packages/factories/vue diff --git a/showcases/package.json b/showcases/package.json index fab0bd5..035fb6e 100644 --- a/showcases/package.json +++ b/showcases/package.json @@ -15,9 +15,11 @@ "@mdi/font": "^7.4.47", "@mui/material": "^5.15.0", "@schepta/adapter-react": "workspace:*", + "@schepta/adapter-vanilla": "workspace:*", "@schepta/adapter-vue": "workspace:*", "@schepta/core": "workspace:*", "@schepta/factory-react": "workspace:*", + "@schepta/factory-vanilla": "workspace:*", "@schepta/factory-vue": "workspace:*", "@tanstack/react-router": "^1.159.5", "formik": "^2.4.6", diff --git a/showcases/src/vanilla/app.ts b/showcases/src/vanilla/app.ts index 6403b54..798a9e1 100644 --- a/showcases/src/vanilla/app.ts +++ b/showcases/src/vanilla/app.ts @@ -1,7 +1,13 @@ /** * Creates the root DOM element for the vanilla app (layout, tabs, panels). - * Does not inject styles or attach event listeners. */ + +import type { FormSchema } from '@schepta/core'; +import { createNativeForm } from './components/NativeForm'; +import { createNativeComplexForm } from './components/NativeComplexForm'; +import simpleFormSchema from '../../../instances/form/simple-form.json'; +import complexFormSchema from '../../../instances/form/complex-form.json'; + export function createAppRoot(): HTMLElement { const container = document.createElement('div'); container.style.cssText = ` @@ -11,36 +17,131 @@ export function createAppRoot(): HTMLElement { margin: 1rem 0; `; - container.innerHTML = ` -
- - - -

Vanilla JS Microfrontend - Schepta Forms

-
- -
-
- - -
- -
-
-

Simple Vanilla Form

-

Esta é uma demonstração básica do Schepta em Vanilla JS (monorepo unificado)

-
-

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

-
-
- -
-

Complex Vanilla Form

-

Aqui seria um formulário mais complexo com Schepta

-
-
-
+ // Header + const header = document.createElement('div'); + header.style.cssText = ` + color: #f7df1e; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + `; + header.innerHTML = ` + + + +

Vanilla JS Microfrontend - Schepta Forms

+ `; + container.appendChild(header); + + // Main content wrapper + const mainContent = document.createElement('div'); + mainContent.style.cssText = ` + background: white; + padding: 1.5rem; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + `; + + // Tabs + const tabsContainer = document.createElement('div'); + tabsContainer.className = 'vanilla-tabs'; + tabsContainer.style.cssText = ` + display: flex; + border-bottom: 1px solid #ddd; + margin-bottom: 1rem; + `; + + const simpleTab = document.createElement('button'); + simpleTab.type = 'button'; + simpleTab.className = 'vanilla-tab active'; + simpleTab.setAttribute('data-test-id', 'simple-form-tab'); + simpleTab.textContent = 'Simple Form'; + simpleTab.style.cssText = ` + padding: 0.5rem 1rem; + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid #f7df1e; + color: #f7df1e; + font-weight: 500; + `; + + const complexTab = document.createElement('button'); + complexTab.type = 'button'; + complexTab.className = 'vanilla-tab'; + complexTab.setAttribute('data-test-id', 'complex-form-tab'); + complexTab.textContent = 'Complex Form'; + complexTab.style.cssText = ` + padding: 0.5rem 1rem; + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; `; + tabsContainer.appendChild(simpleTab); + tabsContainer.appendChild(complexTab); + mainContent.appendChild(tabsContainer); + + // Tab content + const tabContent = document.createElement('div'); + tabContent.className = 'vanilla-tab-content'; + tabContent.style.padding = '1rem 0'; + + // Simple form panel + const simplePanel = document.createElement('div'); + simplePanel.id = 'simple-content'; + simplePanel.className = 'vanilla-tab-panel active'; + createNativeForm(simplePanel, simpleFormSchema as FormSchema); + + // Complex form panel + const complexPanel = document.createElement('div'); + complexPanel.id = 'complex-content'; + complexPanel.className = 'vanilla-tab-panel'; + complexPanel.style.display = 'none'; + createNativeComplexForm(complexPanel, complexFormSchema as FormSchema); + + tabContent.appendChild(simplePanel); + tabContent.appendChild(complexPanel); + mainContent.appendChild(tabContent); + + container.appendChild(mainContent); + + // Attach tab switching behavior + attachTabBehavior(simpleTab, complexTab, simplePanel, complexPanel); + return container; } + +function attachTabBehavior( + simpleTab: HTMLElement, + complexTab: HTMLElement, + simplePanel: HTMLElement, + complexPanel: HTMLElement +) { + const tabs = [simpleTab, complexTab]; + const panels = [simplePanel, complexPanel]; + + tabs.forEach((tab, index) => { + tab.addEventListener('click', () => { + // Update tabs + tabs.forEach(t => { + t.classList.remove('active'); + (t as HTMLElement).style.borderBottomColor = 'transparent'; + (t as HTMLElement).style.color = '#333'; + }); + tab.classList.add('active'); + (tab as HTMLElement).style.borderBottomColor = '#f7df1e'; + (tab as HTMLElement).style.color = '#f7df1e'; + + // Update panels + panels.forEach(p => { + p.classList.remove('active'); + (p as HTMLElement).style.display = 'none'; + }); + panels[index].classList.add('active'); + (panels[index] as HTMLElement).style.display = 'block'; + }); + }); +} diff --git a/showcases/src/vanilla/components/NativeComplexForm.ts b/showcases/src/vanilla/components/NativeComplexForm.ts new file mode 100644 index 0000000..17abb93 --- /dev/null +++ b/showcases/src/vanilla/components/NativeComplexForm.ts @@ -0,0 +1,114 @@ +/** + * Native Complex Form for Vanilla + */ + +import type { FormSchema } from '@schepta/core'; +import { createComponentSpec } from '@schepta/core'; +import { createFormFactory, type FormFactoryResult } from '@schepta/factory-vanilla'; +import { createSocialNameInput } from './SocialNameInput'; +import { createSubmitButton } from './SubmitButton'; + +export function createNativeComplexForm(container: HTMLElement, schema: FormSchema): FormFactoryResult { + // Feature list info box + const infoBox = createInfoBox(); + container.appendChild(infoBox); + + // Form wrapper + const formWrapper = document.createElement('div'); + formWrapper.style.border = '1px solid #ddd'; + formWrapper.style.padding = '24px'; + formWrapper.style.borderRadius = '8px'; + + // External context for custom component + let openSocialName = false; + + const factory = createFormFactory({ + schema, + container: formWrapper, + initialValues: { + userInfo: { + enrollment: '8743', + }, + }, + customComponents: { + socialName: createComponentSpec({ + id: 'socialName', + type: 'field', + component: () => createSocialNameInput, + }), + }, + externalContext: { + get openSocialName() { + return openSocialName; + }, + setOpenSocialName: (value: boolean) => { + openSocialName = value; + }, + }, + onSubmit: (values) => { + console.log('Form submitted:', values); + displaySubmittedValues(container, values); + }, + debug: true, + }); + + container.appendChild(formWrapper); + return factory; +} + +function createInfoBox(): HTMLElement { + const box = document.createElement('div'); + box.style.marginBottom = '24px'; + box.style.padding = '20px'; + box.style.background = '#f9fafb'; + box.style.border = '1px solid #e5e7eb'; + box.style.borderRadius = '8px'; + + box.innerHTML = ` +

What you can see in this form:

+
    +
  • Custom Components (x-custom): Social Name field with toggle behavior
  • +
  • Template Expressions: Conditional visibility (spouseName appears when maritalStatus is 'married')
  • +
  • Disabled Fields: Enrollment field is disabled
  • +
  • Required Fields: Email, Phone, First Name, Last Name, Accept Terms
  • +
  • Grid Layout: 2-column grid with full-width fields (socialName, spouseName)
  • +
  • Input Types: Text, Phone, Select, Date, Textarea, Checkbox
  • +
  • + Sections: Organized with section containers and titles +
      +
    • + User Information contains two subsections: +
        +
      • basicInfo: enrollment, firstName, lastName, socialName, userType, birthDate, maritalStatus, spouseName
      • +
      • additionalInfo: bio, acceptTerms
      • +
      +
    • +
    +
  • +
  • Field Ordering: Custom order via x-ui.order
  • +
  • Initial Values: Pre-filled enrollment value
  • +
  • External Context: State management for custom components
  • +
+ `; + + return box; +} + +function displaySubmittedValues(container: HTMLElement, values: any) { + let resultsDiv = container.querySelector('.submitted-values') as HTMLElement; + if (!resultsDiv) { + resultsDiv = document.createElement('div'); + resultsDiv.className = 'submitted-values'; + resultsDiv.style.marginTop = '24px'; + resultsDiv.style.padding = '16px'; + resultsDiv.style.background = '#f9fafb'; + resultsDiv.style.border = '1px solid #e5e7eb'; + resultsDiv.style.borderRadius = '8px'; + container.appendChild(resultsDiv); + } + + resultsDiv.innerHTML = ` +

Submitted Values:

+
${JSON.stringify(values, null, 2)}
+ `; +} diff --git a/showcases/src/vanilla/components/NativeForm.ts b/showcases/src/vanilla/components/NativeForm.ts new file mode 100644 index 0000000..62afb3e --- /dev/null +++ b/showcases/src/vanilla/components/NativeForm.ts @@ -0,0 +1,45 @@ +/** + * Native Simple Form for Vanilla + */ + +import type { FormSchema } from '@schepta/core'; +import { createFormFactory, type FormFactoryResult } from '@schepta/factory-vanilla'; + +export function createNativeForm(container: HTMLElement, schema: FormSchema): FormFactoryResult { + const wrapper = document.createElement('div'); + wrapper.style.border = '1px solid #ddd'; + wrapper.style.padding = '24px'; + wrapper.style.borderRadius = '8px'; + + const factory = createFormFactory({ + schema, + container: wrapper, + onSubmit: (values) => { + console.log('Form submitted:', values); + displaySubmittedValues(container, values); + }, + debug: true, + }); + + container.appendChild(wrapper); + return factory; +} + +function displaySubmittedValues(container: HTMLElement, values: any) { + let resultsDiv = container.querySelector('.submitted-values') as HTMLElement; + if (!resultsDiv) { + resultsDiv = document.createElement('div'); + resultsDiv.className = 'submitted-values'; + resultsDiv.style.marginTop = '24px'; + resultsDiv.style.padding = '16px'; + resultsDiv.style.background = '#f9fafb'; + resultsDiv.style.border = '1px solid #e5e7eb'; + resultsDiv.style.borderRadius = '8px'; + container.appendChild(resultsDiv); + } + + resultsDiv.innerHTML = ` +

Submitted Values:

+
${JSON.stringify(values, null, 2)}
+ `; +} diff --git a/showcases/src/vanilla/components/SocialNameInput.ts b/showcases/src/vanilla/components/SocialNameInput.ts new file mode 100644 index 0000000..cd412a1 --- /dev/null +++ b/showcases/src/vanilla/components/SocialNameInput.ts @@ -0,0 +1,91 @@ +/** + * Custom Social Name Input Component for Vanilla + */ + +export interface SocialNameInputProps { + name: string; + schema: any; + externalContext: { + openSocialName: boolean; + setOpenSocialName: (value: boolean) => void; + }; + value?: string; + onChange?: (value: string) => void; +} + +export function createSocialNameInput(props: SocialNameInputProps): HTMLElement { + const { name, schema, externalContext, value, onChange } = props; + const label = schema?.['x-component-props']?.label || 'Social Name'; + const placeholder = schema?.['x-component-props']?.placeholder || ''; + + const wrapper = document.createElement('div'); + wrapper.style.marginBottom = '16px'; + wrapper.style.gridColumn = '1 / -1'; + + // Toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.setAttribute('data-test-id', 'social-name-toggle'); + toggleBtn.style.background = 'none'; + toggleBtn.style.border = 'none'; + toggleBtn.style.color = '#2563eb'; + toggleBtn.style.cursor = 'pointer'; + toggleBtn.style.padding = '0'; + toggleBtn.style.fontSize = '14px'; + toggleBtn.style.fontWeight = '500'; + + const updateToggleText = () => { + toggleBtn.textContent = externalContext.openSocialName + ? `- Remove ${label}` + : `+ Add ${label}`; + }; + updateToggleText(); + + // Input container + const inputContainer = document.createElement('div'); + inputContainer.style.marginTop = '8px'; + inputContainer.style.width = '49%'; + inputContainer.style.display = externalContext.openSocialName ? 'block' : 'none'; + + // Label + const inputLabel = document.createElement('label'); + inputLabel.textContent = label; + inputLabel.style.display = 'block'; + inputLabel.style.marginBottom = '4px'; + inputLabel.style.fontSize = '14px'; + inputLabel.style.fontWeight = '500'; + + // Input + const input = document.createElement('input'); + input.type = 'text'; + input.name = name; + input.value = value || ''; + input.placeholder = placeholder; + input.setAttribute('data-test-id', 'social-name-input'); + input.style.display = 'block'; + input.style.width = '100%'; + input.style.padding = '8px 12px'; + input.style.border = '1px solid #d1d5db'; + input.style.borderRadius = '4px'; + input.style.fontSize = '14px'; + + input.addEventListener('input', (e) => { + onChange?.((e.target as HTMLInputElement).value); + }); + + inputContainer.appendChild(inputLabel); + inputContainer.appendChild(input); + + // Toggle behavior + toggleBtn.addEventListener('click', () => { + const newValue = !externalContext.openSocialName; + externalContext.setOpenSocialName(newValue); + inputContainer.style.display = externalContext.openSocialName ? 'block' : 'none'; + updateToggleText(); + }); + + wrapper.appendChild(toggleBtn); + wrapper.appendChild(inputContainer); + + return wrapper; +} diff --git a/showcases/src/vanilla/components/SubmitButton.ts b/showcases/src/vanilla/components/SubmitButton.ts new file mode 100644 index 0000000..b3661b8 --- /dev/null +++ b/showcases/src/vanilla/components/SubmitButton.ts @@ -0,0 +1,18 @@ +export interface SubmitButtonProps { + onSubmit: () => void; +} + +export function createSubmitButton(props: SubmitButtonProps): HTMLElement { + const button = document.createElement('button'); + button.type = 'submit'; + button.textContent = 'Submit'; + button.dataset.testId = 'submit-button-complex-form'; + button.style.padding = '12px 24px'; + button.style.backgroundColor = '#007bff'; + button.style.color = 'white'; + button.style.border = 'none'; + button.style.borderRadius = '4px'; + button.style.cursor = 'pointer'; + button.addEventListener('click', props.onSubmit); + return button; +} \ No newline at end of file diff --git a/tests/e2e/react.spec.ts b/tests/e2e/react.spec.ts index aaf8952..6dcbee4 100644 --- a/tests/e2e/react.spec.ts +++ b/tests/e2e/react.spec.ts @@ -5,8 +5,8 @@ import { extractFieldsFromSchema, FormSchema } from '@schepta/core'; test.describe('React Form Factory', () => { - test.beforeEach(async ({ page, baseURL }) => { - await page.goto(`${baseURL}react/basic`); + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/react/basic'); }); test('should render simple form', async ({ page }) => { @@ -164,8 +164,8 @@ test.describe('React Form Factory', () => { }); test.describe('React Hook Form Integration', () => { - test.beforeEach(async ({ page, baseURL }) => { - await page.goto(`${baseURL}react/basic`); + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/react/basic'); // Navigate to RHF form tab await page.click('[data-test-id*="rhf-form-tab"]'); await page.waitForSelector('[data-test-id^="FormContainer"]', { timeout: 10000 }); @@ -199,8 +199,8 @@ test.describe('React Hook Form Integration', () => { }); test.describe('Formik Integration', () => { - test.beforeEach(async ({ page, baseURL }) => { - await page.goto(`${baseURL}react/basic`); + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/react/basic'); // Navigate to Formik form tab await page.click('[data-test-id*="formik-form-tab"]'); await page.waitForSelector('[data-test-id^="FormContainer"]', { timeout: 10000 }); diff --git a/tests/e2e/vanilla.spec.ts b/tests/e2e/vanilla.spec.ts new file mode 100644 index 0000000..dc97820 --- /dev/null +++ b/tests/e2e/vanilla.spec.ts @@ -0,0 +1,112 @@ +/** + * E2E tests for Vanilla Form Factory + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Vanilla Form Factory', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/vanilla'); + await page.waitForSelector('[data-test-id="simple-form-tab"]', { timeout: 10000 }); + }); + + test('should render simple form', async ({ page }) => { + // Wait for form to be rendered + await page.waitForSelector('input[name="personalInfo.firstName"]', { timeout: 5000 }); + + // Check that form fields exist + await expect(page.locator('input[name="personalInfo.firstName"]')).toBeVisible(); + await expect(page.locator('input[name="personalInfo.lastName"]')).toBeVisible(); + + // Check submit button exists - use first() to avoid strict mode violation + await expect(page.locator('button[data-test-id="submit-button"]').first()).toBeVisible(); + }); + + test('should fill simple form', async ({ page }) => { + await page.waitForSelector('input[name="personalInfo.firstName"]', { timeout: 5000 }); + + await page.fill('input[name="personalInfo.firstName"]', 'John'); + await page.fill('input[name="personalInfo.lastName"]', 'Doe'); + + await page.click('button[data-test-id="submit-button"]'); + + await expect(page.locator('text=Submitted Values')).toBeVisible(); + await expect(page.locator('text=John')).toBeVisible(); + }); + + test('should render complex form', async ({ page }) => { + await page.click('[data-test-id="complex-form-tab"]'); + await page.waitForSelector('input[name="contactInfo.email"]', { timeout: 5000 }); + + await expect(page.locator('input[name="userInfo.enrollment"]')).toBeDisabled(); + await expect(page.locator('[data-test-id="social-name-toggle"]')).toBeVisible(); + }); + + test('should fill complex form', async ({ page }) => { + await page.click('[data-test-id="complex-form-tab"]'); + await page.waitForSelector('input[name="contactInfo.email"]', { timeout: 5000 }); + + const formFields = [ + { name: 'contactInfo.email', value: 'john@example.com' }, + { name: 'contactInfo.phone', value: '(123) 456-7890' }, + { name: 'userInfo.firstName', value: 'John' }, + { name: 'userInfo.lastName', value: 'Doe' }, + ]; + + for (const field of formFields) { + await page.fill(`input[name="${field.name}"]`, field.value); + } + + await page.selectOption('select[name="userInfo.userType"]', 'individual'); + await page.check('input[name="userInfo.acceptTerms"]'); + + // Click the complex form submit button (last one) + await page.locator('button[data-test-id="submit-button"]').last().click(); + + await expect(page.locator('text=Submitted Values')).toBeVisible(); + await expect(page.locator('text=john@example.com')).toBeVisible(); + }); + + test('should validate disabled fields', async ({ page }) => { + await page.click('[data-test-id="complex-form-tab"]'); + await page.waitForSelector('input[name="contactInfo.email"]', { timeout: 5000 }); + + const disabledFields = ['enrollment']; + + for (const field of disabledFields) { + await expect(page.locator(`input[data-test-id*="${field}"]`).first()).toBeDisabled(); + } + }); + + test('should validate required fields', async ({ page }) => { + await page.click('[data-test-id="complex-form-tab"]'); + await page.waitForSelector('input[name="contactInfo.email"]', { timeout: 5000 }); + + const requiredFields = ['email', 'phone']; + + for (const field of requiredFields) { + const fieldLocator = page.locator(`input[data-test-id*="${field}"]`).first(); + await expect(fieldLocator).toHaveAttribute('required', ''); + } + }); + + + test('should interact with custom component', async ({ page }) => { + await page.click('[data-test-id="complex-form-tab"]'); + await page.waitForSelector('input[name="contactInfo.email"]', { timeout: 5000 }); + + // Social name input should not be visible initially + await expect(page.locator('[data-test-id="social-name-input"]')).not.toBeVisible(); + + // Click toggle + await page.click('[data-test-id="social-name-toggle"]'); + await page.waitForTimeout(300); + + // Input should now be visible + await expect(page.locator('[data-test-id="social-name-input"]')).toBeVisible(); + + // Fill and verify + await page.fill('[data-test-id="social-name-input"]', 'Alex'); + await expect(page.locator('[data-test-id="social-name-input"]')).toHaveValue('Alex'); + }); +}); diff --git a/tests/e2e/vue.spec.ts b/tests/e2e/vue.spec.ts index 83026f4..d21b27b 100644 --- a/tests/e2e/vue.spec.ts +++ b/tests/e2e/vue.spec.ts @@ -4,8 +4,8 @@ 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.beforeEach(async ({ page }) => { + await page.goto('http://localhost:3000/vue'); }); test('should render simple form', async ({ page }) => { diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 2169fac..d10d67e 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -12,10 +12,16 @@ export default defineConfig({ trace: 'on-first-retry', actionTimeout: 10 * 1000, // 10 seconds for actions navigationTimeout: 15 * 1000, // 15 seconds for navigation + screenshot: 'only-on-failure', + video: 'retain-on-failure', }, + // Output folders for test artifacts + outputDir: '../test-results', + snapshotDir: '../test-screenshots', projects: [ { name: 'react', + testMatch: '**/*react.spec.ts', use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:3000/', @@ -23,6 +29,15 @@ export default defineConfig({ }, { name: 'vue', + testMatch: '**/*vue.spec.ts', + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://localhost:3000/', + }, + }, + { + name: 'vanilla', + testMatch: '**/*vanilla.spec.ts', use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:3000/',