diff --git a/examples/react/package.json b/examples/react/package.json index 28546ce..c48eff8 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -11,6 +11,7 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "^3.9.0", "@mui/material": "^5.15.0", "@schepta/adapter-react": "workspace:*", "@schepta/core": "workspace:*", diff --git a/examples/react/src/basic-ui/components/Forms/FormWithFormik.tsx b/examples/react/src/basic-ui/components/Forms/FormWithFormik.tsx index 9df3140..916376a 100644 --- a/examples/react/src/basic-ui/components/Forms/FormWithFormik.tsx +++ b/examples/react/src/basic-ui/components/Forms/FormWithFormik.tsx @@ -2,79 +2,20 @@ * Form with Formik * * Example component demonstrating how to use Schepta with Formik. - * This shows how to inject custom Formik components via the component registry. + * This shows how to inject custom Formik components via the component registry + * with AJV validation using createFormikValidator. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { FormFactory } from '@schepta/factory-react'; -import { createComponentSpec, FormSchema } from '@schepta/core'; +import { + createComponentSpec, + FormSchema, +} from '@schepta/core'; import { FormikFieldWrapper } from '../formik/FormikFieldWrapper'; import { FormikFormContainer } from '../formik/FormikFormContainer'; -// Import the same input components from basic-ui -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'; - -/** - * Formik-specific component registry. - * Registers the Formik FieldWrapper and FormContainer to use - * Formik for form state management. - */ -const formikComponents = { - // Register Formik FieldWrapper - this makes all fields use Formik's context - FieldWrapper: createComponentSpec({ - id: 'FieldWrapper', - type: 'field-wrapper', - factory: () => FormikFieldWrapper, - }), - // Register Formik FormContainer - this provides the Formik context - FormContainer: createComponentSpec({ - id: 'FormContainer', - type: 'FormContainer', - factory: () => FormikFormContainer, - }), - // Standard input components (same as basic-ui) - InputText: createComponentSpec({ - id: 'InputText', - type: 'field', - factory: () => InputText, - }), - InputSelect: createComponentSpec({ - id: 'InputSelect', - type: 'field', - factory: () => InputSelect, - }), - InputCheckbox: createComponentSpec({ - id: 'InputCheckbox', - type: 'field', - factory: () => InputCheckbox, - }), - InputTextarea: createComponentSpec({ - id: 'InputTextarea', - type: 'field', - factory: () => InputTextarea, - }), - InputNumber: createComponentSpec({ - id: 'InputNumber', - type: 'field', - factory: () => InputNumber, - }), - InputDate: createComponentSpec({ - id: 'InputDate', - type: 'field', - factory: () => InputDate, - }), - InputPhone: createComponentSpec({ - id: 'InputPhone', - type: 'field', - factory: () => InputText, - defaultProps: { type: 'tel' }, - }), -}; +import { components } from '../ComponentRegistry'; interface FormWithFormikProps { schema: FormSchema; @@ -83,12 +24,28 @@ interface FormWithFormikProps { /** * FormWithFormik Component * - * Renders a form using Formik for state management. + * Renders a form using Formik for state management with AJV validation. * Demonstrates how to integrate external form libraries with Schepta. */ export const FormWithFormik: React.FC = ({ schema }) => { const [submittedValues, setSubmittedValues] = useState | null>(null); + const formikComponents = useMemo(() => ({ + // Register Formik FieldWrapper - this makes all fields use Formik's context + FieldWrapper: createComponentSpec({ + id: 'FieldWrapper', + type: 'field-wrapper', + factory: () => FormikFieldWrapper, + }), + // Register Formik FormContainer with validation - this provides the Formik context + FormContainer: createComponentSpec({ + id: 'FormContainer', + type: 'FormContainer', + factory: () => FormikFormContainer, + }), + ...components, + }), []); + const handleSubmit = (values: Record) => { console.log('Form submitted (Formik):', values); setSubmittedValues(values); @@ -110,7 +67,7 @@ export const FormWithFormik: React.FC = ({ schema }) => { borderRadius: '4px', fontSize: '14px', }}> - This form uses Formik for state management. + This form uses Formik with AJV validation. The FieldWrapper and FormContainer are custom Formik implementations. RHFFieldWrapper, - }), - // Register RHF FormContainer - this provides the FormProvider context - FormContainer: createComponentSpec({ - id: 'FormContainer', - type: 'FormContainer', - factory: () => RHFFormContainer, - }), - // Standard input components (same as basic-ui) - InputText: createComponentSpec({ - id: 'InputText', - type: 'field', - factory: () => InputText, - }), - InputSelect: createComponentSpec({ - id: 'InputSelect', - type: 'field', - factory: () => InputSelect, - }), - InputCheckbox: createComponentSpec({ - id: 'InputCheckbox', - type: 'field', - factory: () => InputCheckbox, - }), - InputTextarea: createComponentSpec({ - id: 'InputTextarea', - type: 'field', - factory: () => InputTextarea, - }), - InputNumber: createComponentSpec({ - id: 'InputNumber', - type: 'field', - factory: () => InputNumber, - }), - InputDate: createComponentSpec({ - id: 'InputDate', - type: 'field', - factory: () => InputDate, - }), - InputPhone: createComponentSpec({ - id: 'InputPhone', - type: 'field', - factory: () => InputText, - defaultProps: { type: 'tel' }, - }), -}; +import { components } from '../ComponentRegistry'; interface FormWithRHFProps { schema: FormSchema; @@ -83,12 +21,28 @@ interface FormWithRHFProps { /** * FormWithRHF Component * - * Renders a form using react-hook-form for state management. + * Renders a form using react-hook-form for state management with AJV validation. * Demonstrates how to integrate external form libraries with Schepta. */ export const FormWithRHF: React.FC = ({ schema }) => { const [submittedValues, setSubmittedValues] = useState | null>(null); + // Create RHF components with validation config + const rhfComponents = useMemo(() => ({ + // Register RHF FieldWrapper - this makes all fields use RHF's Controller + FieldWrapper: createComponentSpec({ + id: 'FieldWrapper', + type: 'field-wrapper', + factory: () => RHFFieldWrapper, + }), + // Register RHF FormContainer with validation - this provides the FormProvider context + FormContainer: createComponentSpec({ + id: 'FormContainer', + type: 'FormContainer', + factory: () => RHFFormContainer, + }), + }), []); + const handleSubmit = (values: Record) => { console.log('Form submitted (RHF):', values); setSubmittedValues(values); @@ -110,12 +64,12 @@ export const FormWithRHF: React.FC = ({ schema }) => { borderRadius: '4px', fontSize: '14px', }}> - This form uses react-hook-form for state management. + This form uses react-hook-form with AJV validation. The FieldWrapper and FormContainer are custom RHF implementations. diff --git a/examples/react/src/basic-ui/components/Forms/NativeForm.tsx b/examples/react/src/basic-ui/components/Forms/NativeForm.tsx index 1f11dfc..2ad93b1 100644 --- a/examples/react/src/basic-ui/components/Forms/NativeForm.tsx +++ b/examples/react/src/basic-ui/components/Forms/NativeForm.tsx @@ -1,14 +1,20 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { FormFactory } from '@schepta/factory-react'; -import { FormSchema } from "@schepta/core"; +import { FormSchema, generateValidationSchema } from "@schepta/core"; interface FormProps { schema: FormSchema; + initialValues?: Record; } -export const NativeForm = ({ schema }: FormProps) => { +export const NativeForm = ({ schema, initialValues: externalInitialValues }: FormProps) => { const [submittedValues, setSubmittedValues] = useState | null>(null); + const initialValues = useMemo(() => { + const { initialValues: schemaInitialValues } = generateValidationSchema(schema); + return { ...schemaInitialValues, ...externalInitialValues }; + }, [schema, externalInitialValues]); + const handleSubmit = (values: Record) => { console.log('Form submitted:', values); setSubmittedValues(values); @@ -25,6 +31,7 @@ export const NativeForm = ({ schema }: FormProps) => { > diff --git a/examples/react/src/basic-ui/components/formik/FormikFormContainer.tsx b/examples/react/src/basic-ui/components/formik/FormikFormContainer.tsx index 5c4f157..dcf2285 100644 --- a/examples/react/src/basic-ui/components/formik/FormikFormContainer.tsx +++ b/examples/react/src/basic-ui/components/formik/FormikFormContainer.tsx @@ -1,40 +1,63 @@ /** * Formik Form Container - * - * Custom FormContainer that uses Formik for state management. - * This demonstrates how to integrate Formik with Schepta forms. + * + * Custom FormContainer that uses Formik for state management + * with AJV validation via createFormikValidator. */ -import React from 'react'; -import { Formik, Form } from 'formik'; -import type { FormContainerProps } from '@schepta/factory-react'; +import React, { useMemo } from "react"; +import { Formik, Form } from "formik"; +import type { FormContainerProps } from "@schepta/factory-react"; +import { + createFormikValidator, + FormSchema, + generateValidationSchema, +} from "@schepta/core"; +import { useSchepta } from "@schepta/adapter-react"; /** - * Formik-based FormContainer component. - * Wraps children with Formik context for form state management. - * - * This component: - * - Creates a Formik form context - * - Wraps children with Formik's Form component - * - Handles form submission with Formik's onSubmit - * - * @example - * ```tsx - * import { createComponentSpec } from '@schepta/core'; - * - * const components = { - * FormContainer: createComponentSpec({ - * id: 'FormContainer', - * type: 'FormContainer', - * factory: () => FormikFormContainer, - * }), - * }; - * ``` + * Creates a Formik-based FormContainer component with validation. + * Uses Formik with a custom validate function powered by AJV. + * + * @param validate - Validation function from createFormikValidator + * @param defaultValues - Default form values + * @returns A FormContainer component configured with validation + * + */ export const FormikFormContainer: React.FC = ({ children, onSubmit, }) => { + const { schema } = useSchepta(); + const { initialValues, validate } = useMemo(() => { + const { initialValues } = generateValidationSchema(schema as FormSchema, { + messages: { + en: { + required: '{{label}} is required', + minLength: '{{label}} must be at least {{minLength}} characters', + maxLength: '{{label}} must be at most {{maxLength}} characters', + pattern: '{{label}} format is invalid', + } + }, + locale: 'en' + }); + + const validate = createFormikValidator(schema as FormSchema, { + messages: { + en: { + required: '{{label}} is required', + minLength: '{{label}} must be at least {{minLength}} characters', + maxLength: '{{label}} must be at most {{maxLength}} characters', + pattern: '{{label}} format is invalid', + } + }, + locale: 'en' + }); + + return { initialValues, validate }; + }, [schema]); + const handleSubmit = (values: Record) => { if (onSubmit) { onSubmit(values); @@ -43,30 +66,31 @@ export const FormikFormContainer: React.FC = ({ return ( - {({ isSubmitting }) => ( + {({ isSubmitting, errors, touched }) => (
{children} {onSubmit && ( -
+
)} @@ -74,4 +98,4 @@ export const FormikFormContainer: React.FC = ({ )} ); -}; +} diff --git a/examples/react/src/basic-ui/components/rhf/RHFFormContainer.tsx b/examples/react/src/basic-ui/components/rhf/RHFFormContainer.tsx index 8e08b07..398bcfb 100644 --- a/examples/react/src/basic-ui/components/rhf/RHFFormContainer.tsx +++ b/examples/react/src/basic-ui/components/rhf/RHFFormContainer.tsx @@ -1,73 +1,96 @@ /** * RHF Form Container * - * Custom FormContainer that uses react-hook-form for state management. - * This demonstrates how to integrate RHF with Schepta forms. + * Custom FormContainer that uses react-hook-form for state management + * with AJV validation via @hookform/resolvers/ajv. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; +import { ajvResolver } from '@hookform/resolvers/ajv'; import type { FormContainerProps } from '@schepta/factory-react'; +import { FormSchema, generateValidationSchema } from '@schepta/core'; +import { useSchepta } from '@schepta/adapter-react'; /** - * RHF-based FormContainer component. - * Creates its own useForm context and wraps children with FormProvider. + * Creates an RHF-based FormContainer component with validation. + * Uses react-hook-form's useForm with ajvResolver for validation. * - * This component: - * - Creates a RHF form context with useForm() - * - Wraps children with FormProvider for nested components to access - * - Handles form submission with RHF's handleSubmit + * @param jsonSchema - JSON Schema for AJV validation + * @param defaultValues - Default form values + * @returns A FormContainer component configured with validation * * @example * ```tsx - * import { createComponentSpec } from '@schepta/core'; + * import { createComponentSpec, generateValidationSchema } from '@schepta/core'; + * import { createRHFFormContainer } from './RHFFormContainer'; + * + * const { jsonSchema, initialValues } = generateValidationSchema(formSchema); * * const components = { * FormContainer: createComponentSpec({ * id: 'FormContainer', * type: 'FormContainer', - * factory: () => RHFFormContainer, + * factory: () => createRHFFormContainer(jsonSchema, initialValues), * }), * }; * ``` */ + export const RHFFormContainer: React.FC = ({ children, onSubmit, }) => { - const methods = useForm(); + const { schema } = useSchepta(); + const { jsonSchema, initialValues } = useMemo(() => + generateValidationSchema(schema as FormSchema, { + messages: { + en: { + required: '{{label}} is required', + minLength: '{{label}} must be at least {{minLength}} characters', + maxLength: '{{label}} must be at most {{maxLength}} characters', + pattern: '{{label}} format is invalid', + } + }, + locale: 'en' + }), [schema]); + + const methods = useForm({ + defaultValues: initialValues, + resolver: jsonSchema ? ajvResolver(jsonSchema as any) : undefined, + }); - const handleFormSubmit = methods.handleSubmit((values) => { - if (onSubmit) { - onSubmit(values); - } - }); + const handleFormSubmit = methods.handleSubmit((values) => { + if (onSubmit) { + onSubmit(values); + } + }); - return ( - - - {children} - {onSubmit && ( -
- -
- )} - -
- ); -}; + return ( + +
+ {children} + {onSubmit && ( +
+ +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/examples/react/src/basic-ui/pages/BasicFormPage.tsx b/examples/react/src/basic-ui/pages/BasicFormPage.tsx index d3bd562..c1538c7 100644 --- a/examples/react/src/basic-ui/pages/BasicFormPage.tsx +++ b/examples/react/src/basic-ui/pages/BasicFormPage.tsx @@ -13,6 +13,11 @@ export function BasicFormPage() { const [tabValue, setTabValue] = useState(0); const simpleSchema = simpleFormSchema as FormSchema; const complexSchema = complexFormSchema as FormSchema; + const initialValues = { + userInfo: { + enrollment: '8743', + } + } const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); @@ -34,7 +39,7 @@ export function BasicFormPage() { - + diff --git a/instances/form/complex-form.json b/instances/form/complex-form.json index bde79a7..cfade1f 100644 --- a/instances/form/complex-form.json +++ b/instances/form/complex-form.json @@ -88,12 +88,30 @@ "type": "object", "x-component": "FormSectionGroup", "properties": { - "firstName": { + "enrollment": { "type": "object", "x-component": "FormField", "x-ui": { "order": 1 }, + "properties": { + "enrollment": { + "type": "string", + "x-component": "InputText", + "x-component-props": { + "label": "Enrollment", + "placeholder": "Enter your enrollment", + "disabled": true + } + } + } + }, + "firstName": { + "type": "object", + "x-component": "FormField", + "x-ui": { + "order": 2 + }, "properties": { "firstName": { "type": "string", @@ -110,7 +128,7 @@ "type": "object", "x-component": "FormField", "x-ui": { - "order": 2 + "order": 3 }, "properties": { "lastName": { diff --git a/instances/form/simple-form.json b/instances/form/simple-form.json index 481bc65..d15b680 100644 --- a/instances/form/simple-form.json +++ b/instances/form/simple-form.json @@ -40,9 +40,6 @@ "x-component-props": { "label": "First Name", "placeholder": "Enter your first name" - }, - "x-rules": { - "required": true } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7acfee0..2d5fbed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,7 +33,6 @@ export * from './schema/schema-types'; export * from './provider'; // Utils -export * from './utils/build-initial-values'; export * from './utils/jexl-config'; export * from './utils/sanitize-props'; diff --git a/packages/core/src/schema/schema-types.ts b/packages/core/src/schema/schema-types.ts index 801d8ca..61fea33 100644 --- a/packages/core/src/schema/schema-types.ts +++ b/packages/core/src/schema/schema-types.ts @@ -1,221 +1,188 @@ /** - * Schema de formulário + * Form schema definition */ export interface FormSchema { $id: string $schema?: string type: "object" - properties: { - [k: string]: FormSectionContainer - } + properties: FormSchemaProperties "x-component": "FormContainer" } +export interface FormSchemaProperties { + [k: string]: FormSectionContainer +} /** - * This interface was referenced by `undefined`'s JSON-Schema definition + * This interface was referenced by `FormSchemaProperties`'s JSON-Schema definition * via the `patternProperty` "^.*$". */ export interface FormSectionContainer { type: "object" - "x-ui": { - /** - * Order of the section in the form (lower values appear first) - */ - order: number - } - properties: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^.*$". - */ - [k: string]: FormSectionTitle | FormSectionGroupContainer - } + "x-ui": xUi + properties: FormSectionContainerProperties "x-component": "FormSectionContainer" - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: FormSectionContainerComponentProps +} +export interface xUi { + /** + * Order of the section in the form (lower values appear first) + */ + order: number +} +export interface FormSectionContainerProperties { + /** + * This interface was referenced by `FormSectionContainerProperties`'s JSON-Schema definition + * via the `patternProperty` "^.*$". + */ + [k: string]: FormSectionTitle | FormSectionGroupContainer } export interface FormSectionTitle { type: "object" - "x-slots"?: { - [k: string]: unknown - } + "x-slots"?: XSlots "x-content": string "x-component": "FormSectionTitle" - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: FormSectionTitleComponentProps +} +export interface XSlots { + [k: string]: unknown +} +export interface FormSectionTitleComponentProps { + [k: string]: unknown } export interface FormSectionGroupContainer { type: "object" - properties: { - [k: string]: FormSectionGroup - } + properties: FormSectionGroupContainerProperties "x-component": "FormSectionGroupContainer" - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: FormSectionGroupContainerComponentProps +} +export interface FormSectionGroupContainerProperties { + [k: string]: FormSectionGroup } /** - * This interface was referenced by `undefined`'s JSON-Schema definition + * This interface was referenced by `FormSectionGroupContainerProperties`'s JSON-Schema definition * via the `patternProperty` "^.*$". */ export interface FormSectionGroup { type: "object" - properties: { - [k: string]: FormField - } + properties: FormSectionGroupProperties "x-component": "FormSectionGroup" - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: FormSectionGroupComponentProps +} +export interface FormSectionGroupProperties { + [k: string]: FormField } /** - * FormField wrapper (Grid) reutilizável para qualquer tipo de input + * FormField wrapper (Grid) reusable for any type of input * - * This interface was referenced by `undefined`'s JSON-Schema definition + * This interface was referenced by `FormSectionGroupProperties`'s JSON-Schema definition * via the `patternProperty` "^.*$". */ export interface FormField { type: "object" "x-component": "FormField" - "x-ui": { - /** - * Order of the field in the form (lower values appear first) - */ - order: number - } - properties: { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z_][a-zA-Z0-9_]*$". - */ - [k: string]: - | InputText - | InputSelect - | InputCheckbox - | InputDate - | InputPhone - | InputAutocomplete - | InputTextarea - | InputNumber - } - "x-component-props"?: { - [k: string]: unknown - } + "x-ui": xUi + properties: FormFieldProperties + "x-component-props"?: FormFieldComponentProps +} +export interface FormFieldProperties { + /** + * This interface was referenced by `FormFieldProperties`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z_][a-zA-Z0-9_]*$". + */ + [k: string]: + | InputText + | InputSelect + | InputCheckbox + | InputDate + | InputPhone + | InputAutocomplete + | InputTextarea + | InputNumber } export interface InputText { type: "string" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputText" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputTextComponentProps +} +export interface InputTextComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputSelect { type: "string" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputSelect" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputSelectComponentProps +} +export interface InputSelectComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputCheckbox { type: "boolean" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputCheckbox" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputCheckboxComponentProps +} +export interface InputCheckboxComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputDate { type: "string" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputDate" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputDateComponentProps +} +export interface InputDateComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputPhone { type: "string" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputPhone" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputPhoneComponentProps +} +export interface InputPhoneComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputAutocomplete { type: "string" - "x-rules"?: { - [k: string]: unknown - } "x-component": "InputAutocomplete" - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - label?: string - placeholder?: string - [k: string]: unknown - } + "x-component-props"?: InputAutocompleteComponentProps +} +export interface InputAutocompleteComponentProps { + label?: string + placeholder?: string + [k: string]: unknown } export interface InputTextarea { type: "string" "x-component": "InputTextarea" - "x-rules"?: { - [k: string]: unknown - } - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: InputTextareaComponentProps +} +export interface InputTextareaComponentProps { + [k: string]: unknown } export interface InputNumber { type: "number" "x-component": "InputNumber" - "x-rules"?: { - [k: string]: unknown - } - "x-reactions"?: { - [k: string]: unknown - } - "x-component-props"?: { - [k: string]: unknown - } + "x-component-props"?: InputNumberComponentProps +} +export interface InputNumberComponentProps { + [k: string]: unknown +} +export interface FormFieldComponentProps { + [k: string]: unknown +} +export interface FormSectionGroupComponentProps { + [k: string]: unknown +} +export interface FormSectionGroupContainerComponentProps { + [k: string]: unknown +} +export interface FormSectionContainerComponentProps { + [k: string]: unknown } diff --git a/packages/core/src/utils/build-initial-values.ts b/packages/core/src/utils/build-initial-values.ts deleted file mode 100644 index 1e0e48c..0000000 --- a/packages/core/src/utils/build-initial-values.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Build Initial Values from Schema - * - * Extracts default values from form schema structure. - */ - -import type { FormSchema } from '../schema/schema-types'; - -/** - * Build initial values for form fields from a FormSchema. - * This utility extracts default values based on the component type - * defined in the schema structure. - */ -export function buildInitialValuesFromSchema(schema: FormSchema): Record { - const initialValues: Record = {}; - - const resolveFields = (props: any) => { - if (!props || typeof props !== 'object') return; - - for (const sectionKey in props) { - const section = props[sectionKey]; - if (!section?.properties) continue; - - for (const groupKey in section.properties) { - const group = section.properties[groupKey]; - if (!group?.properties) continue; - - for (const fieldKey in group.properties) { - const field = group.properties[fieldKey]; - const component = field?.['x-component']; - - if (!component) continue; - - initialValues[fieldKey] = getDefaultValueForComponent(component); - } - } - } - }; - - resolveFields(schema.properties); - return initialValues; -} - -/** - * Get default value for a component type. - * This maps component types to their appropriate default values. - */ -function getDefaultValueForComponent(component: string): any { - switch (component) { - case 'InputCheckbox': - return false; - case 'InputText': - case 'InputCpf': - case 'InputPhone': - case 'InputDate': - case 'InputAutocomplete': - case 'InputTextarea': - case 'InputNumber': - return ''; - case 'SelectMultiple': - return []; - case 'InputSelect': - return ''; - default: - return undefined; - } -} - diff --git a/packages/core/src/validation/form-validator.ts b/packages/core/src/validation/form-validator.ts new file mode 100644 index 0000000..cc1725d --- /dev/null +++ b/packages/core/src/validation/form-validator.ts @@ -0,0 +1,160 @@ +/** + * Form Validator + * + * Creates validation functions for Formik and native forms using AJV. + * Equivalent to ajvResolver but for Formik and other form libraries. + */ + +import Ajv, { ErrorObject } from 'ajv'; +import addErrors from 'ajv-errors'; +import type { FormSchema } from '../schema/schema-types'; +import { generateValidationSchema, type SchemaParserOptions } from './schema-parser'; + +/** + * Formik-compatible validation errors object + */ +export interface FormikValidationErrors { + [field: string]: string; +} + +/** + * Generic form validation result + */ +export interface FormValidationResult { + valid: boolean; + errors: FormikValidationErrors; +} + +/** + * Parse AJV errors into Formik-compatible format + * + * @param ajvErrors - Array of AJV error objects + * @returns Formik-compatible errors object with nested paths (e.g., 'personalInfo.firstName') + */ +function parseAjvErrors(ajvErrors: ErrorObject[] | null | undefined): FormikValidationErrors { + if (!ajvErrors || ajvErrors.length === 0) { + return {}; + } + + const errors: FormikValidationErrors = {}; + + for (const error of ajvErrors) { + // Get the base path from instancePath + let path = error.instancePath; + + // Handle required errors - AJV returns the parent path, we need to append the missing property + if (error.keyword === 'required' && error.params.missingProperty) { + path = path ? `${path}/${error.params.missingProperty}` : `/${error.params.missingProperty}`; + } + + // Convert JSON pointer path to dot notation: /personalInfo/firstName -> personalInfo.firstName + const fieldPath = path.startsWith('/') ? path.substring(1) : path; + const normalizedPath = fieldPath.replace(/\//g, '.'); + + // Only set if not already set (first error takes precedence) + if (normalizedPath && !errors[normalizedPath] && error.message) { + errors[normalizedPath] = error.message; + } + } + + return errors; +} + +/** + * Creates a validate function for Formik using AJV. + * This is equivalent to ajvResolver but returns a Formik-compatible validate function. + * + * @param schema - The FormSchema to validate against + * @param options - Parser options for i18n and customization + * @returns A validate function compatible with Formik's validate prop + * + * @example + * ```typescript + * import { Formik } from 'formik'; + * import { generateValidationSchema, createFormikValidator } from '@schepta/core'; + * + * const { initialValues } = generateValidationSchema(formSchema); + * const validate = createFormikValidator(formSchema, { locale: 'pt' }); + * + * + * {({ errors }) => ( + * // form content + * )} + * + * ``` + */ +export function createFormikValidator( + schema: FormSchema, + options?: SchemaParserOptions +): (values: Record) => FormikValidationErrors { + // Generate JSON Schema from FormSchema + const { jsonSchema } = generateValidationSchema(schema, options); + + // Create AJV instance with custom error messages support + const ajv = new Ajv({ + allErrors: true, + validateSchema: true, + strict: false, + }); + + // Add error message support + addErrors(ajv); + + // Compile the schema + const validate = ajv.compile(jsonSchema); + + // Return the validation function + return (values: Record): FormikValidationErrors => { + const valid = validate(values); + + if (valid) { + return {}; + } + + return parseAjvErrors(validate.errors); + }; +} + +/** + * Creates a generic form validator that returns both validation status and errors. + * Can be used with native forms or any custom form implementation. + * + * @param schema - The FormSchema to validate against + * @param options - Parser options for i18n and customization + * @returns A validate function that returns { valid, errors } + * + * @example + * ```typescript + * const validate = createFormValidator(formSchema); + * + * const handleSubmit = (e) => { + * const formData = new FormData(e.target); + * const values = Object.fromEntries(formData); + * const { valid, errors } = validate(values); + * + * if (!valid) { + * console.error('Validation errors:', errors); + * return; + * } + * + * // Submit form + * }; + * ``` + */ +export function createFormValidator( + schema: FormSchema, + options?: SchemaParserOptions +): (values: Record) => FormValidationResult { + const formikValidator = createFormikValidator(schema, options); + + return (values: Record): FormValidationResult => { + const errors = formikValidator(values); + const valid = Object.keys(errors).length === 0; + + return { valid, errors }; + }; +} diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts index 0765572..e547ff2 100644 --- a/packages/core/src/validation/index.ts +++ b/packages/core/src/validation/index.ts @@ -1,9 +1,27 @@ /** * Validation Module * - * JSON Schema validation for form instances. + * This module provides two types of validation: + * + * 1. Schema Structure Validation (development-time) + * - Validates that FormSchema JSON is well-formed + * - Uses schema-validator.ts + * + * 2. Form Data Validation (runtime) + * - Validates user input against rules in x-component-props + * - Uses schema-parser.ts, form-validator.ts + * - Compatible with ajvResolver, Formik, and native forms */ +// Types export * from './types'; + +// Schema structure validation (development-time) export * from './schema-validator'; +// Schema traversal utilities (shared) +export * from './schema-traversal'; + +// Form data validation (runtime) +export * from './schema-parser'; +export * from './form-validator'; diff --git a/packages/core/src/validation/schema-parser.ts b/packages/core/src/validation/schema-parser.ts new file mode 100644 index 0000000..a64c168 --- /dev/null +++ b/packages/core/src/validation/schema-parser.ts @@ -0,0 +1,312 @@ +/** + * Schema Parser + * + * Generates AJV-compatible JSON Schema from FormSchema for form data validation. + * The output is compatible with @hookform/resolvers/ajv. + */ + +import type { FormSchema } from '../schema/schema-types'; +import { + extractFieldsFromSchema, + buildInitialValues, + type FieldNode +} from './schema-traversal'; + +/** + * Validation messages for a single field + */ +export interface ValidationMessages { + required?: string; + minLength?: string; + maxLength?: string; + pattern?: string; + minimum?: string; + maximum?: string; + format?: string; +} + +/** + * Result of parsing a FormSchema + */ +export interface ParsedSchema { + /** JSON Schema for AJV validation - pass to ajvResolver */ + jsonSchema: Record; + /** Initial values extracted from schema (nested structure) */ + initialValues: Record; + /** Field paths extracted from schema (e.g., 'personalInfo.firstName') */ + fields: string[]; +} + +/** + * Options for schema parser + */ +export interface SchemaParserOptions { + /** Custom error messages (i18n) - keyed by locale */ + messages?: Record; + /** Default locale key (defaults to 'en') */ + locale?: string; + /** Whether to allow additional properties in the JSON Schema */ + additionalProperties?: boolean; +} + +/** + * Default validation messages with interpolation placeholders + */ +const DEFAULT_MESSAGES: ValidationMessages = { + required: '{{label}} is required', + minLength: '{{label}} must be at least {{minLength}} characters', + maxLength: '{{label}} must be at most {{maxLength}} characters', + pattern: '{{label}} format is invalid', + minimum: '{{label}} must be at least {{min}}', + maximum: '{{label}} must be at most {{max}}', + format: '{{label}} format is invalid', +}; + +/** + * Interpolate message template with field data + * + * @param template - Message template with {{placeholders}} + * @param data - Data object with values to interpolate + * @returns Interpolated message + */ +function interpolateMessage(template: string, data: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + return data[key] !== undefined ? String(data[key]) : `{{${key}}}`; + }); +} + +/** + * Build error messages for a field based on its validation rules + * + * @param field - Field node from schema + * @param messages - Validation messages to use + * @returns AJV errorMessage object + */ +function buildErrorMessages( + field: FieldNode, + messages: ValidationMessages +): Record | undefined { + const errorMessage: Record = {}; + const { props, label, name } = field; + const fieldLabel = label || name; + + const data = { + label: fieldLabel, + field: name, + minLength: props.minLength, + maxLength: props.maxLength, + min: props.min, + max: props.max, + }; + + // Required validation - AJV uses minLength:1 for strings + if (props.required && messages.required) { + errorMessage.minLength = interpolateMessage(messages.required, data); + } + + // MinLength validation (when not just for required) + if (props.minLength !== undefined && props.minLength > 1 && messages.minLength) { + errorMessage.minLength = interpolateMessage(messages.minLength, data); + } + + // MaxLength validation + if (props.maxLength !== undefined && messages.maxLength) { + errorMessage.maxLength = interpolateMessage(messages.maxLength, data); + } + + // Pattern validation + if (props.pattern && messages.pattern) { + errorMessage.pattern = interpolateMessage(messages.pattern, data); + } + + // Minimum validation (for numbers) + if (props.min !== undefined && messages.minimum) { + errorMessage.minimum = interpolateMessage(messages.minimum, data); + } + + // Maximum validation (for numbers) + if (props.max !== undefined && messages.maximum) { + errorMessage.maximum = interpolateMessage(messages.maximum, data); + } + + return Object.keys(errorMessage).length > 0 ? errorMessage : undefined; +} + +/** + * Build JSON Schema property for a single field + * + * @param field - Field node from schema + * @param messages - Validation messages to use + * @returns JSON Schema property definition + */ +function buildFieldProperty( + field: FieldNode, + messages: ValidationMessages +): Record { + const { type, props } = field; + const property: Record = { type }; + + // Handle required for string fields - use minLength: 1 + if (props.required && type === 'string') { + property.minLength = props.minLength !== undefined + ? Math.max(1, props.minLength) + : 1; + } else if (props.minLength !== undefined) { + property.minLength = props.minLength; + } + + // MaxLength + if (props.maxLength !== undefined) { + property.maxLength = props.maxLength; + } + + // Pattern + if (props.pattern) { + property.pattern = props.pattern; + } + + // Minimum (for numbers) + if (props.min !== undefined && type === 'number') { + property.minimum = props.min; + } + + // Maximum (for numbers) + if (props.max !== undefined && type === 'number') { + property.maximum = props.max; + } + + // Required for boolean (must be true) + if (props.required && type === 'boolean') { + property.const = true; + } + + // Add error messages + const errorMessage = buildErrorMessages(field, messages); + if (errorMessage) { + property.errorMessage = errorMessage; + } + + return property; +} + +/** + * Set a nested property in a JSON Schema object structure + * Creates intermediate 'type: object' nodes as needed + * + * @param schema - Schema object to modify + * @param path - Dot-separated path (e.g., 'personalInfo.firstName') + * @param property - Property definition to set + * @param isRequired - Whether the field is required + */ +function setNestedSchemaProperty( + schema: Record, + path: string, + property: Record, + isRequired: boolean +): void { + const keys = path.split('.'); + let current = schema; + + // Navigate/create nested structure + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + if (!current.properties) { + current.properties = {}; + } + + if (!current.properties[key]) { + current.properties[key] = { + type: 'object', + properties: {}, + additionalProperties: true, + }; + } + + current = current.properties[key]; + } + + // Set the final property + const finalKey = keys[keys.length - 1]; + if (!current.properties) { + current.properties = {}; + } + current.properties[finalKey] = property; + + // Handle required at the correct level + if (isRequired) { + if (!current.required) { + current.required = []; + } + if (!current.required.includes(finalKey)) { + current.required.push(finalKey); + } + } +} + +/** + * Generates a JSON Schema from FormSchema for AJV validation. + * The output is compatible with @hookform/resolvers/ajv. + * Creates nested schema structure matching the form field paths. + * + * @param schema - The FormSchema to parse + * @param options - Parser options + * @returns ParsedSchema with jsonSchema, initialValues, and fields + * + * @example + * ```typescript + * import { ajvResolver } from '@hookform/resolvers/ajv'; + * import { generateValidationSchema } from '@schepta/core'; + * + * const { jsonSchema, initialValues, fields } = generateValidationSchema(formSchema, { + * messages: { + * en: { required: '{{label}} is required' }, + * pt: { required: '{{label}} é obrigatório' } + * }, + * locale: 'pt' + * }); + * + * const { register, handleSubmit } = useForm({ + * defaultValues: initialValues, + * resolver: ajvResolver(jsonSchema), + * }); + * ``` + */ +export function generateValidationSchema( + schema: FormSchema, + options: SchemaParserOptions = {} +): ParsedSchema { + const { + messages = {}, + locale = 'en', + additionalProperties = true + } = options; + + // Get messages for the specified locale, falling back to defaults + const localeMessages = messages[locale] || DEFAULT_MESSAGES; + const mergedMessages = { ...DEFAULT_MESSAGES, ...localeMessages }; + + // Extract fields from schema + const fieldNodes = extractFieldsFromSchema(schema); + + // Build nested JSON Schema + const jsonSchema: Record = { + type: 'object', + properties: {}, + additionalProperties, + }; + + for (const field of fieldNodes) { + const property = buildFieldProperty(field, mergedMessages); + const isRequired = field.props.required === true; + + // Set property at the correct nested path + setNestedSchemaProperty(jsonSchema, field.path, property, isRequired); + } + + return { + jsonSchema, + initialValues: buildInitialValues(schema), + fields: fieldNodes.map(f => f.path), + }; +} diff --git a/packages/core/src/validation/schema-traversal.ts b/packages/core/src/validation/schema-traversal.ts new file mode 100644 index 0000000..f59dc94 --- /dev/null +++ b/packages/core/src/validation/schema-traversal.ts @@ -0,0 +1,269 @@ +/** + * Schema Traversal Utilities + * + * Shared utilities for traversing FormSchema structure and extracting field information. + * Used by schema-parser, form-validator, and test utilities. + */ + +import type { FormFieldProperties, FormSchema } from '../schema/schema-types'; + +/** + * Represents a field node extracted from the schema + */ +export interface FieldNode { + /** Field name (leaf key in the schema, e.g., 'firstName') */ + name: string; + /** Full path to the field (e.g., 'personalInfo.firstName') */ + path: string; + /** JSON Schema type (string, number, boolean) */ + type: 'string' | 'number' | 'boolean'; + /** Component name (InputText, InputSelect, etc.) */ + component: string; + /** Label from x-component-props */ + label?: string; + /** All props from x-component-props */ + props: Record; +} + +type InputComponentTypes = NonNullable; +type InputComponentNames = InputComponentTypes['x-component']; + +/** + * Input component names that represent form fields + */ + +const INPUT_COMPONENTS: readonly InputComponentNames[] = [ + 'InputText', + 'InputSelect', + 'InputPhone', + 'InputDate', + 'InputTextarea', + 'InputNumber', + 'InputCheckbox', + 'InputAutocomplete', +] as const; + +/** + * Components that are containers (not fields, but included in path) + */ +const CONTAINER_COMPONENTS = [ + 'FormSectionContainer', + 'FormSectionGroupContainer', + 'FormSectionGroup', + 'FormField', + 'FormSectionTitle', +] as const; + +/** + * Check if a component name is an input component + * + * @param component - Component name to check + * @returns True if the component is an input type + */ +export function isInputComponent(component: string): boolean { + return component.startsWith('Input') || + INPUT_COMPONENTS.includes(component as typeof INPUT_COMPONENTS[number]); +} + +/** + * Check if a component is a container that should be included in the path + */ +function isContainerComponent(component: string): boolean { + return CONTAINER_COMPONENTS.includes(component as typeof CONTAINER_COMPONENTS[number]); +} + +/** + * Get the JSON Schema type for a component + * + * @param component - Component name + * @returns JSON Schema type + */ +export function getTypeForComponent(component: string): 'string' | 'number' | 'boolean' { + switch (component) { + case 'InputCheckbox': + return 'boolean'; + case 'InputNumber': + return 'number'; + default: + return 'string'; + } +} + +/** + * Get default value for a component type + * + * @param component - Component name + * @returns Default value for the field + */ +export function getDefaultValueForComponent(component: string): any { + switch (component) { + case 'InputCheckbox': + return false; + case 'InputNumber': + return undefined; + case 'SelectMultiple': + return []; + case 'InputText': + case 'InputCpf': + case 'InputPhone': + case 'InputDate': + case 'InputAutocomplete': + case 'InputTextarea': + case 'InputSelect': + return ''; + default: + return undefined; + } +} + +/** + * Visitor function type for traverseFormSchema + */ +export type FieldVisitor = (field: FieldNode) => void; + +/** + * Traverse a FormSchema and call visitor for each input field found. + * Tracks the full path to each field matching the orchestrator's name path logic. + * + * Path building rules (matching renderer-orchestrator.ts): + * - Direct children of FormContainer (root properties) are included + * - Field components (type: 'field') add their key to the path + * - Other containers just pass through the parent path + * + * @param schema - The FormSchema to traverse + * @param visitor - Function called for each field found + * + * @example + * ```typescript + * traverseFormSchema(schema, (field) => { + * console.log(`Found field: ${field.name} at path ${field.path} (${field.component})`); + * }); + * ``` + */ +export function traverseFormSchema(schema: FormSchema, visitor: FieldVisitor): void { + function traverse(obj: any, currentPath: string[] = [], isRootProperty: boolean = false): void { + if (!obj || typeof obj !== 'object') { + return; + } + + const component = obj['x-component']; + + // Check if this is a FormField component containing an input + if (component === 'FormField' && obj.properties) { + // Look for the actual input field inside the FormField + for (const [key, value] of Object.entries(obj.properties)) { + if (value && typeof value === 'object' && 'x-component' in value) { + const inputComponent = (value as any)['x-component']; + const props = (value as any)['x-component-props'] || {}; + + // Check if it's an input component + if (inputComponent && isInputComponent(inputComponent)) { + // Build the full path: currentPath + field name + // This matches how the orchestrator builds name paths + const fieldPath = [...currentPath, key].join('.'); + + const field: FieldNode = { + name: key, + path: fieldPath, + type: getTypeForComponent(inputComponent), + component: inputComponent, + label: props.label, + props, + }; + visitor(field); + } + } + } + return; // Don't traverse deeper inside FormField + } + + // Recursively traverse properties + if (obj.properties) { + for (const [key, value] of Object.entries(obj.properties)) { + if (value && typeof value === 'object') { + const childComponent = (value as any)['x-component']; + + // Determine if this key should be added to the path + // This matches the orchestrator's logic in renderer-orchestrator.ts: + // - Direct children of FormContainer (root properties) are included + // - Only FormSectionContainer adds to path (it's a root property container) + // - Other containers (FormSectionGroup, FormSectionGroupContainer, etc.) don't add to path + const isRootChild = component === 'FormContainer'; + const shouldIncludeInPath = isRootChild && childComponent === 'FormSectionContainer'; + + const newPath = shouldIncludeInPath ? [...currentPath, key] : currentPath; + + traverse(value, newPath, isRootChild); + } + } + } + } + + traverse(schema, [], false); +} + +/** + * Extract all fields from a FormSchema + * + * @param schema - The FormSchema to extract fields from + * @returns Array of FieldNode objects with full paths + * + * @example + * ```typescript + * const fields = extractFieldsFromSchema(schema); + * const fieldNames = fields.map(f => f.name); + * const fieldPaths = fields.map(f => f.path); + * const requiredFields = fields.filter(f => f.props.required); + * ``` + */ +export function extractFieldsFromSchema(schema: FormSchema): FieldNode[] { + const fields: FieldNode[] = []; + traverseFormSchema(schema, (field) => { + fields.push(field); + }); + return fields; +} + +/** + * Set a value at a nested path in an object + * + * @param obj - Object to modify + * @param path - Dot-separated path (e.g., 'personalInfo.firstName') + * @param value - Value to set + */ +function setNestedValue(obj: Record, path: string, value: any): void { + const keys = path.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current) || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key]; + } + + current[keys[keys.length - 1]] = value; +} + +/** + * Build initial values from a FormSchema with proper nested structure. + * + * @param schema - The FormSchema to extract initial values from + * @returns Object with nested structure matching form field paths + * + * @example + * ```typescript + * const initialValues = buildInitialValues(schema); + * { personalInfo: { firstName: '', lastName: '' }, acceptTerms: false } + * ``` + */ +export function buildInitialValues(schema: FormSchema): Record { + const initialValues: Record = {}; + + traverseFormSchema(schema, (field) => { + setNestedValue(initialValues, field.path, getDefaultValueForComponent(field.component)); + }); + + return initialValues; +} diff --git a/packages/core/src/validation/types.ts b/packages/core/src/validation/types.ts index 76371a8..000ba51 100644 --- a/packages/core/src/validation/types.ts +++ b/packages/core/src/validation/types.ts @@ -2,10 +2,19 @@ * Validation Types * * Type definitions for schema validation system. + * + * This module contains types for: + * - Schema structure validation (development-time validation of FormSchema JSON) + * - Form data validation (runtime validation of user input) */ +// ============================================================================ +// Schema Structure Validation Types (development-time) +// Used by schema-validator.ts for validating FormSchema JSON is well-formed +// ============================================================================ + /** - * Represents a single validation error + * Represents a single validation error from schema structure validation */ export interface ValidationError { /** JSON pointer path to the error location (e.g., "/properties/personalInfo/properties/firstName") */ @@ -23,7 +32,7 @@ export interface ValidationError { } /** - * Result of schema validation + * Result of schema structure validation */ export interface ValidationResult { /** Whether the instance is valid against the schema */ @@ -33,7 +42,7 @@ export interface ValidationResult { } /** - * Options for schema validation + * Options for schema structure validation */ export interface ValidationOptions { /** Whether to collect all errors or stop at the first one */ @@ -44,3 +53,23 @@ export interface ValidationOptions { throwOnError?: boolean; } +// ============================================================================ +// Form Data Validation Types (runtime) +// Re-exported from schema-traversal.ts, schema-parser.ts, and form-validator.ts +// ============================================================================ + +// Re-export types from schema-traversal +export type { FieldNode, FieldVisitor } from './schema-traversal'; + +// Re-export types from schema-parser +export type { + ValidationMessages, + ParsedSchema, + SchemaParserOptions +} from './schema-parser'; + +// Re-export types from form-validator +export type { + FormikValidationErrors, + FormValidationResult +} from './form-validator'; diff --git a/packages/factories/react/src/hooks/use-schepta-form.ts b/packages/factories/react/src/hooks/use-schepta-form.ts index 57c789d..b9cb286 100644 --- a/packages/factories/react/src/hooks/use-schepta-form.ts +++ b/packages/factories/react/src/hooks/use-schepta-form.ts @@ -7,7 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { FormSchema, FormAdapter } from '@schepta/core'; import { createNativeReactFormAdapter, NativeReactFormAdapter } from '@schepta/adapter-react'; -import { buildInitialValuesFromSchema } from '@schepta/core'; +import { buildInitialValues } from '@schepta/core'; export interface ScheptaFormOptions { /** Initial values for the form */ @@ -62,7 +62,7 @@ export function useScheptaForm( // Build default values from schema if no initial values provided const defaultValues = useMemo(() => { - const schemaDefaults = buildInitialValuesFromSchema(schema); + const schemaDefaults = buildInitialValues(schema); return { ...schemaDefaults, ...initialValues }; }, [schema, initialValues]); @@ -103,7 +103,7 @@ export function useScheptaForm( useEffect(() => { if (initialValues !== undefined) { const newDefaults = { - ...buildInitialValuesFromSchema(schema), + ...buildInitialValues(schema), ...initialValues, }; setFormState(newDefaults); diff --git a/packages/factories/src/schemas/form-schema.json b/packages/factories/src/schemas/form-schema.json index cc23121..022c653 100644 --- a/packages/factories/src/schemas/form-schema.json +++ b/packages/factories/src/schemas/form-schema.json @@ -1,492 +1,462 @@ { - "$id": "form-schema", - "type": "object", - "$defs": { - "FormField": { - "type": "object", - "required": [ - "type", - "x-component", - "properties", - "x-ui" - ], - "properties": { - "type": { - "const": "object" - }, - "x-component": { - "const": "FormField" - }, - "x-ui": { - "type": "object", - "required": ["order"], - "properties": { - "order": { - "type": "integer", - "description": "Order of the field in the form (lower values appear first)" - } - }, - "additionalProperties": false - }, + "$id": "form-schema", + "type": "object", + "title": "FormSchema", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Form schema definition", + "$defs": { + "FormField": { + "title": "FormField", + "type": "object", + "description": "FormField wrapper (Grid) reusable for any type of input", + "required": [ + "type", + "x-component", + "properties", + "x-ui" + ], + "properties": { + "type": { + "const": "object" + }, + "x-component": { + "const": "FormField" + }, + "x-ui": { + "title": "xUi", + "type": "object", + "required": ["order"], "properties": { - "type": "object", - "minProperties": 1, - "patternProperties": { - "^[a-zA-Z_][a-zA-Z0-9_]*$": { - "oneOf": [ - { - "$ref": "#/$defs/InputText" - }, - { - "$ref": "#/$defs/InputSelect" - }, - { - "$ref": "#/$defs/InputCheckbox" - }, - { - "$ref": "#/$defs/InputDate" - }, - { - "$ref": "#/$defs/InputPhone" - }, - { - "$ref": "#/$defs/InputAutocomplete" - }, - { - "$ref": "#/$defs/InputTextarea" - }, - { - "$ref": "#/$defs/InputNumber" - } - ] - } - }, - "additionalProperties": false + "order": { + "type": "integer", + "description": "Order of the field in the form (lower values appear first)" + } }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "additionalProperties": false }, - "description": "FormField wrapper (Grid) reutilizável para qualquer tipo de input", - "additionalProperties": false - }, - "InputText": { - "type": "object", - "required": [ - "type", - "x-component" - ], "properties": { - "type": { - "const": "string" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputText" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true - }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "title": "FormFieldProperties", + "type": "object", + "minProperties": 1, + "patternProperties": { + "^[a-zA-Z_][a-zA-Z0-9_]*$": { + "oneOf": [ + { + "$ref": "#/$defs/InputText" + }, + { + "$ref": "#/$defs/InputSelect" + }, + { + "$ref": "#/$defs/InputCheckbox" + }, + { + "$ref": "#/$defs/InputDate" + }, + { + "$ref": "#/$defs/InputPhone" + }, + { + "$ref": "#/$defs/InputAutocomplete" + }, + { + "$ref": "#/$defs/InputTextarea" + }, + { + "$ref": "#/$defs/InputNumber" + } + ] + } + }, + "additionalProperties": false + }, + "x-component-props": { + "title": "FormFieldComponentProps", + "type": "object", + "additionalProperties": true + } }, - "InputDate": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "string" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputDate" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputText": { + "title": "InputText", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputText" + }, + "x-component-props": { + "title": "InputTextComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "additionalProperties": true + } }, - "InputPhone": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "string" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputPhone" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputDate": { + "title": "InputDate", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputDate" + }, + "x-component-props": { + "title": "InputDateComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "additionalProperties": true + } }, - "InputSelect": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "string" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputSelect" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputPhone": { + "title": "InputPhone", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputPhone" + }, + "x-component-props": { + "title": "InputPhoneComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "additionalProperties": true + } }, - "InputCheckbox": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "boolean" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputCheckbox" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputSelect": { + "title": "InputSelect", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputSelect" + }, + "x-component-props": { + "title": "InputSelectComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "additionalProperties": true + } }, - "InputTextarea": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "string" - }, - "x-component": { - "const": "InputTextarea" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputCheckbox": { + "title": "InputCheckbox", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "boolean" + }, + "x-component": { + "const": "InputCheckbox" + }, + "x-component-props": { + "title": "InputCheckboxComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "InputTextarea": { + "title": "InputTextarea", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputTextarea" }, - "additionalProperties": false + "x-component-props": { + "title": "InputTextareaComponentProps", + "type": "object", + "additionalProperties": true + } }, - "InputNumber": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "number" - }, - "x-component": { - "const": "InputNumber" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-reactions": { - "type": "object", - "additionalProperties": true - }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "additionalProperties": false + }, + "InputNumber": { + "title": "InputNumber", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "number" }, - "additionalProperties": false + "x-component": { + "const": "InputNumber" + }, + "x-component-props": { + "title": "InputNumberComponentProps", + "type": "object", + "additionalProperties": true + } }, - "FormSectionGroup": { - "type": "object", - "required": [ - "type", - "x-component", - "properties" - ], + "additionalProperties": false + }, + "FormSectionGroup": { + "title": "FormSectionGroup", + "type": "object", + "required": [ + "type", + "x-component", + "properties" + ], + "properties": { + "type": { + "const": "object" + }, "properties": { - "type": { - "const": "object" - }, - "properties": { - "type": "object", - "patternProperties": { - "^.*$": { - "$ref": "#/$defs/FormField" - } - }, - "additionalProperties": false - }, - "x-component": { - "const": "FormSectionGroup" - }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "title": "FormSectionGroupProperties", + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/FormField" + } + }, + "additionalProperties": false }, - "additionalProperties": false + "x-component": { + "const": "FormSectionGroup" + }, + "x-component-props": { + "title": "FormSectionGroupComponentProps", + "type": "object", + "additionalProperties": true + } }, - "FormSectionTitle": { - "type": "object", - "required": [ - "type", - "x-component", - "x-content" - ], - "properties": { - "type": { - "const": "object" - }, - "x-slots": { - "type": "object" - }, - "x-content": { - "type": "string" - }, - "x-component": { - "const": "FormSectionTitle" - }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "additionalProperties": false + }, + "FormSectionTitle": { + "title": "FormSectionTitle", + "type": "object", + "required": [ + "type", + "x-component", + "x-content" + ], + "properties": { + "type": { + "const": "object" + }, + "x-slots": { + "title": "xSlots", + "type": "object" + }, + "x-content": { + "type": "string" }, - "additionalProperties": false + "x-component": { + "const": "FormSectionTitle" + }, + "x-component-props": { + "title": "FormSectionTitleComponentProps", + "type": "object", + "additionalProperties": true + } }, - "InputAutocomplete": { - "type": "object", - "required": [ - "type", - "x-component" - ], - "properties": { - "type": { - "const": "string" - }, - "x-rules": { - "type": "object", - "additionalProperties": true - }, - "x-component": { - "const": "InputAutocomplete" - }, - "x-reactions": { - "type": "object", - "additionalProperties": true + "additionalProperties": false + }, + "InputAutocomplete": { + "title": "InputAutocomplete", + "type": "object", + "required": [ + "type", + "x-component" + ], + "properties": { + "type": { + "const": "string" + }, + "x-component": { + "const": "InputAutocomplete" + }, + "x-component-props": { + "title": "InputAutocompleteComponentProps", + "type": "object", + "properties": { + "label": { "type": "string" }, + "placeholder": { "type": "string" } }, - "x-component-props": { - "type": "object", - "properties": { - "label": { "type": "string" }, - "placeholder": { "type": "string" } - }, - "additionalProperties": true - } - }, - "additionalProperties": false + "additionalProperties": true + } }, - "FormSectionContainer": { - "type": "object", - "required": [ - "type", - "x-component", - "properties", - "x-ui" - ], - "properties": { - "type": { - "const": "object" - }, - "x-ui": { - "type": "object", - "required": ["order"], - "properties": { - "order": { - "type": "integer", - "description": "Order of the section in the form (lower values appear first)" - } - }, - "additionalProperties": false - }, + "additionalProperties": false + }, + "FormSectionContainer": { + "title": "FormSectionContainer", + "type": "object", + "required": [ + "type", + "x-component", + "properties", + "x-ui" + ], + "properties": { + "type": { + "const": "object" + }, + "x-ui": { + "title": "xUi", + "type": "object", + "required": ["order"], "properties": { - "type": "object", - "patternProperties": { - "^.*$": { - "oneOf": [ - { - "$ref": "#/$defs/FormSectionTitle" - }, - { - "$ref": "#/$defs/FormSectionGroupContainer" - } - ] - } - }, - "additionalProperties": false + "order": { + "type": "integer", + "description": "Order of the section in the form (lower values appear first)" + } }, - "x-component": { - "const": "FormSectionContainer" - }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "additionalProperties": false }, - "additionalProperties": false - }, - "FormSectionGroupContainer": { - "type": "object", - "required": [ - "type", - "x-component", - "properties" - ], "properties": { - "type": { - "const": "object" - }, - "properties": { - "type": "object", - "patternProperties": { - "^.*$": { - "$ref": "#/$defs/FormSectionGroup" - } - }, - "additionalProperties": false - }, - "x-component": { - "const": "FormSectionGroupContainer" - }, - "x-component-props": { - "type": "object", - "additionalProperties": true - } + "title": "FormSectionContainerProperties", + "type": "object", + "patternProperties": { + "^.*$": { + "oneOf": [ + { + "$ref": "#/$defs/FormSectionTitle" + }, + { + "$ref": "#/$defs/FormSectionGroupContainer" + } + ] + } + }, + "additionalProperties": false }, - "additionalProperties": false - } - }, - "title": "Form Schema", - "$schema": "http://json-schema.org/draft-07/schema#", - "required": [ - "$id", - "type", - "x-component", - "properties" - ], - "properties": { - "$id": { - "type": "string" - }, - "$schema": { - "type": "string" - }, - "type": { - "const": "object" + "x-component": { + "const": "FormSectionContainer" + }, + "x-component-props": { + "title": "FormSectionContainerComponentProps", + "type": "object", + "additionalProperties": true + } }, + "additionalProperties": false + }, + "FormSectionGroupContainer": { + "title": "FormSectionGroupContainer", + "type": "object", + "required": [ + "type", + "x-component", + "properties" + ], "properties": { - "type": "object", - "patternProperties": { - "^.*$": { - "$ref": "#/$defs/FormSectionContainer" - } + "type": { + "const": "object" + }, + "properties": { + "title": "FormSectionGroupContainerProperties", + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/FormSectionGroup" + } + }, + "additionalProperties": false + }, + "x-component": { + "const": "FormSectionGroupContainer" }, - "additionalProperties": false + "x-component-props": { + "title": "FormSectionGroupContainerComponentProps", + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + } + }, + "required": [ + "$id", + "type", + "x-component", + "properties" + ], + "properties": { + "$id": { + "type": "string" + }, + "$schema": { + "type": "string" + }, + "type": { + "const": "object" + }, + "properties": { + "title": "FormSchemaProperties", + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/$defs/FormSectionContainer" + } }, - "x-component": { - "const": "FormContainer" - } + "additionalProperties": false }, - "description": "Schema de formulário", - "additionalProperties": false - } \ No newline at end of file + "x-component": { + "const": "FormContainer" + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/factories/vanilla/src/form-factory.ts b/packages/factories/vanilla/src/form-factory.ts index 6e8bd09..7ee64d4 100644 --- a/packages/factories/vanilla/src/form-factory.ts +++ b/packages/factories/vanilla/src/form-factory.ts @@ -11,7 +11,7 @@ import { setFactoryDefaultComponents, createComponentSpec, } from '@schepta/core'; -import { buildInitialValuesFromSchema } from '@schepta/core'; +import { buildInitialValues } from '@schepta/core'; import { renderForm } from './form-renderer'; import { createDefaultFormContainer, @@ -76,7 +76,7 @@ export function createFormFactory(options: FormFactoryOptions): FormFactoryResul : (providerConfig?.debug?.enabled || false); const formAdapter = createVanillaFormAdapter( - options.initialValues || buildInitialValuesFromSchema(options.schema) + options.initialValues || buildInitialValues(options.schema) ); const runtime = createVanillaRuntimeAdapter(); diff --git a/packages/factories/vue/src/form-factory.ts b/packages/factories/vue/src/form-factory.ts index fc31d4a..d20c266 100644 --- a/packages/factories/vue/src/form-factory.ts +++ b/packages/factories/vue/src/form-factory.ts @@ -13,7 +13,7 @@ import { setFactoryDefaultComponents, createComponentSpec, } from '@schepta/core'; -import { buildInitialValuesFromSchema } from '@schepta/core'; +import { buildInitialValues } from '@schepta/core'; import { FormRenderer } from './form-renderer'; import { DefaultFormContainer, DefaultSubmitButton } from './components'; @@ -164,10 +164,9 @@ export function createFormFactory(defaultProps: FormFactoryProps) { ? props.debug : (defaultProps.debug !== undefined ? defaultProps.debug : (providerConfig?.debug?.enabled || false)); - const initialValues = props.initialValues || defaultProps.initialValues; - const onSubmit = props.onSubmit || defaultProps.onSubmit; + const formAdapter = ref(createVueFormAdapter( - props.initialValues || buildInitialValuesFromSchema(props.schema) + props.initialValues || buildInitialValues(props.schema) )); const runtime = ref(createVueRuntimeAdapter()); @@ -217,12 +216,6 @@ export function createFormFactory(defaultProps: FormFactoryProps) { const rootComponentKey = computed(() => (props.schema as any)['x-component'] || 'FormContainer'); - // Resolve SubmitButton component from registry (provider or local) or use default - const SubmitButtonComponent = computed(() => { - const customComponent = mergedComponents.SubmitButton?.factory?.({}, runtime.value); - return customComponent || DefaultSubmitButton; - }); - // Watch form state to trigger reactivity watch(() => formAdapter.value.getValues(), () => { // Force re-render when form values change @@ -233,7 +226,7 @@ export function createFormFactory(defaultProps: FormFactoryProps) { if (props.initialValues) { formAdapter.value.reset(props.initialValues); } else if (newSchema) { - formAdapter.value.reset(buildInitialValuesFromSchema(newSchema as FormSchema)); + formAdapter.value.reset(buildInitialValues(newSchema as FormSchema)); } }, { deep: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b942882..9ebfc10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@emotion/styled': specifier: ^11.11.0 version: 11.14.1(@emotion/react@11.14.0)(@types/react@18.3.27)(react@18.3.1) + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.10.0(react-hook-form@7.68.0) '@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) @@ -153,7 +156,7 @@ importers: version: 5.9.3 vite: specifier: ^5.0.8 - version: 5.4.21(@types/node@20.19.25) + version: 5.4.21 examples/vue: dependencies: @@ -1520,6 +1523,14 @@ packages: dev: true optional: true + /@hookform/resolvers@3.10.0(react-hook-form@7.68.0): + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.68.0(react@18.3.1) + dev: false + /@iconify-json/simple-icons@1.2.61: resolution: {integrity: sha512-DG6z3VEAxtDEw/SuZssZ/E8EvhjBhFQqxpEo3uckRKiia3LfZHmM4cx4RsaO2qX1Bqo9uadR5c/hYavvUQVuHw==} dependencies: @@ -2197,7 +2208,7 @@ packages: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.25) + vite: 5.4.21 transitivePeerDependencies: - supports-color dev: true @@ -5489,6 +5500,44 @@ packages: transitivePeerDependencies: - supports-color + /vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vite@5.4.21(@types/node@20.19.25): resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} diff --git a/tests/e2e/react.spec.ts b/tests/e2e/react.spec.ts index 55c77a2..d54c571 100644 --- a/tests/e2e/react.spec.ts +++ b/tests/e2e/react.spec.ts @@ -1,8 +1,7 @@ import { test, expect } from '@playwright/test'; import simpleFormSchema from '../../instances/form/simple-form.json'; import complexFormSchema from '../../instances/form/complex-form.json'; -import { extractFormFields, extractRequiredFields } from 'tests/utils/extractJsonFields'; - +import { extractFieldsFromSchema, FormSchema } from '@schepta/core'; test.describe('React Form Factory', () => { @@ -11,26 +10,25 @@ test.describe('React Form Factory', () => { }); test('should render simple form', async ({ page }) => { - const fields = extractFormFields(simpleFormSchema); - + const fields = extractFieldsFromSchema(simpleFormSchema as FormSchema); + // Wait for form to be rendered await page.waitForSelector('[data-test-id*="firstName"]', { timeout: 10000 }); - + // Check if all form fields from schema are present for (const field of fields) { - await expect(page.locator(`[data-test-id*="${field}"]`)).toBeVisible(); + await expect(page.locator(`[data-test-id*="${field.name}"]`)).toBeVisible(); } }); test('should render complex form with all field types', async ({ page, baseURL }) => { await page.click('[data-test-id*="complex-form-tab"]'); - - const fields = extractFormFields(complexFormSchema); - console.log('Extracted fields from complex schema:', fields); - + + const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).map(field => field.name); + // Wait for form to be rendered await page.waitForSelector('[data-test-id*="email"]', { timeout: 10000 }); - + // Check if all form fields from schema are present for (const field of fields) { await expect(page.locator(`[data-test-id*="${field}"]`).first()).toBeVisible(); @@ -38,41 +36,45 @@ test.describe('React Form Factory', () => { }); test('should fill form fields', async ({ page }) => { - const fields = extractFormFields(complexFormSchema); - const firstNameField = fields.find(f => f === 'firstName'); - const lastNameField = fields.find(f => f === 'lastName'); - const emailField = fields.find(f => f === 'email'); - const phoneField = fields.find(f => f === 'phone'); - const birthDateField = fields.find(f => f === 'birthDate'); - const userTypeField = fields.find(f => f === 'userType'); - const bioField = fields.find(f => f === 'bio'); - const acceptTermsField = fields.find(f => f === 'acceptTerms'); + const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter(field => field.props.disabled !== true); + + const inputValues = { + 'email': 'john.doe@example.com', + 'phone': '(123) 456-7890', + 'firstName': 'John', + 'lastName': 'Doe', + 'userType': 'individual', + 'birthDate': '1990-01-01', + 'bio': 'I am a software engineer', + 'acceptTerms': true, + } await page.click('[data-test-id*="complex-form-tab"]'); await page.waitForSelector('[data-test-id*="email"]', { timeout: 10000 }); - await page.locator(`[data-test-id*="${emailField}"]`).first().fill('john.doe@example.com'); - await page.locator(`[data-test-id*="${phoneField}"]`).first().fill('(123) 456-7890'); - await page.locator(`[data-test-id*="${firstNameField}"]`).first().fill('John'); - await page.locator(`[data-test-id*="${lastNameField}"]`).first().fill('Doe'); - await page.locator(`[data-test-id*="${userTypeField}"]`).first().selectOption('individual'); - await page.locator(`[data-test-id*="${birthDateField}"]`).first().fill('1990-01-01'); - await page.locator(`[data-test-id*="${bioField}"]`).first().fill('I am a software engineer'); - await page.locator(`[data-test-id*="${acceptTermsField}"]`).first().check(); - - await expect(page.locator(`[data-test-id*="${emailField}"]`).first()).toHaveValue('john.doe@example.com'); - await expect(page.locator(`[data-test-id*="${phoneField}"]`).first()).toHaveValue('(123) 456-7890'); - await expect(page.locator(`[data-test-id*="${firstNameField}"]`).first()).toHaveValue('John'); - await expect(page.locator(`[data-test-id*="${lastNameField}"]`).first()).toHaveValue('Doe'); - await expect(page.locator(`[data-test-id*="${userTypeField}"]`).first()).toHaveValue('individual'); - await expect(page.locator(`[data-test-id*="${birthDateField}"]`).first()).toHaveValue('1990-01-01'); - await expect(page.locator(`[data-test-id*="${bioField}"]`).first()).toHaveValue('I am a software engineer'); - await expect(page.locator(`[data-test-id*="${acceptTermsField}"]`).first()).toBeChecked(); + for (const field of fields) { + if (field.component === 'InputText' || field.component === 'InputPhone' || field.component === 'InputDate' || field.component === 'InputTextarea') { + await page.locator(`[data-test-id*="${field.name}"]`).first().fill(inputValues[field.name as keyof typeof inputValues] as string); + } else if (field.component === 'InputSelect') { + await page.locator(`[data-test-id*="${field.name}"]`).first().selectOption(inputValues[field.name as keyof typeof inputValues] as string); + } else if (field.component === 'InputCheckbox') { + await page.locator(`[data-test-id*="${field.name}"]`).first().check(); + } + } + for (const field of fields) { + if (field.component === 'InputCheckbox') { + await expect(page.locator(`[data-test-id*="${field.name}"]`).first()).toBeChecked(); + } else { + await expect(page.locator(`[data-test-id*="${field.name}"]`).first()).toHaveValue(inputValues[field.name as keyof typeof inputValues] as string); + } + } }); test('should validate required fields', async ({ page }) => { - const requiredFields = extractRequiredFields(complexFormSchema); + 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('[data-test-id*="email"]', { timeout: 10000 }); @@ -85,3 +87,73 @@ test.describe('React Form Factory', () => { }); }); +test.describe('React Hook Form Integration', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(`${baseURL || 'http://localhost:3000'}/basic`); + // Navigate to RHF form tab + await page.click('[data-test-id*="rhf-form-tab"]'); + await page.waitForSelector('[data-test-id="FormContainer"]', { timeout: 10000 }); + }); + + test('should render RHF form with initial values', async ({ page }) => { + // Verify the form container is rendered + await expect(page.locator('[data-test-id="FormContainer"]')).toBeVisible(); + + // Verify fields are present + await expect(page.locator('[data-test-id*="firstName"]')).toBeVisible(); + await expect(page.locator('[data-test-id*="lastName"]')).toBeVisible(); + }); + + test('should submit RHF form with valid data', async ({ page }) => { + // Fill form fields + await page.locator('[data-test-id*="firstName"]').fill('John'); + await page.locator('[data-test-id*="lastName"]').fill('Doe'); + + // Submit the form + await page.click('[data-test-id="submit-button"]'); + + // Wait for submitted values to appear + await page.waitForSelector('text=Submitted Values', { timeout: 5000 }); + + // Verify submission occurred + const submittedText = await page.textContent('pre'); + expect(submittedText).toContain('John'); + expect(submittedText).toContain('Doe'); + }); +}); + +test.describe('Formik Integration', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(`${baseURL || 'http://localhost:3000'}/basic`); + // Navigate to Formik form tab + await page.click('[data-test-id*="formik-form-tab"]'); + await page.waitForSelector('[data-test-id="FormContainer"]', { timeout: 10000 }); + }); + + test('should render Formik form with initial values', async ({ page }) => { + // Verify the form container is rendered + await expect(page.locator('[data-test-id="FormContainer"]')).toBeVisible(); + + // Verify fields are present + await expect(page.locator('[data-test-id*="firstName"]')).toBeVisible(); + await expect(page.locator('[data-test-id*="lastName"]')).toBeVisible(); + }); + + test('should submit Formik form with valid data', async ({ page }) => { + // Fill form fields + await page.locator('[data-test-id*="firstName"]').fill('Jane'); + await page.locator('[data-test-id*="lastName"]').fill('Smith'); + + // Submit the form + await page.click('[data-test-id="submit-button"]'); + + // Wait for submitted values to appear + await page.waitForSelector('text=Submitted Values', { timeout: 5000 }); + + // Verify submission occurred + const submittedText = await page.textContent('pre'); + expect(submittedText).toContain('Jane'); + expect(submittedText).toContain('Smith'); + }); +}); + diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 8025c2c..5f3e835 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ { command: 'pnpm --filter examples-react dev', url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, + reuseExistingServer: true, timeout: 120 * 1000, stdout: 'ignore', stderr: 'pipe', diff --git a/tests/utils/extractJsonFields.ts b/tests/utils/extractJsonFields.ts deleted file mode 100644 index 95631f0..0000000 --- a/tests/utils/extractJsonFields.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { FormSchema } from '@schepta/core'; - -function isInputComponent(component: string): boolean { - return component.startsWith('Input') || - component === 'InputText' || - component === 'InputSelect' || - component === 'InputPhone' || - component === 'InputDate' || - component === 'InputTextarea' || - component === 'InputNumber' || - component === 'InputCheckbox'; -} - -/** - * Recursively extracts all form field names from a schema JSON - * A field is identified by having x-component: "FormField" and containing - * a property with an input component (InputText, InputSelect, etc.) - */ -export function extractFormFields(schema: FormSchema | any): string[] { - const fields: string[] = []; - - function traverse(obj: any, path: string[] = []) { - if (!obj || typeof obj !== 'object') { - return; - } - - // Check if this is a FormField component - if (obj['x-component'] === 'FormField' && obj.properties) { - // Look for the actual input field inside the FormField - for (const [key, value] of Object.entries(obj.properties)) { - if (value && typeof value === 'object' && 'x-component' in value) { - const component = (value as any)['x-component']; - // Check if it's an input component - if (component && isInputComponent(component)) { - fields.push(key); - } - } - } - } - - // Recursively traverse properties - if (obj.properties) { - for (const [key, value] of Object.entries(obj.properties)) { - if (value && typeof value === 'object') { - traverse(value, [...path, key]); - } - } - } - } - - traverse(schema); - return fields; -} - -/** - * Extracts required fields from schema based on x-rules.required or required prop - */ -export function extractRequiredFields(schema: FormSchema | any): string[] { - const requiredFields: string[] = []; - - function traverse(obj: any, currentFieldName: string | null = null) { - if (!obj || typeof obj !== 'object') { - return; - } - - // Check if this is an input component with required validation - if (obj['x-component'] && ( - isInputComponent(obj['x-component']) - )) { - // Check for required in x-rules or x-component-props - const isRequired = - obj['x-component-props']?.required === true; - - if (isRequired && currentFieldName) { - requiredFields.push(currentFieldName); - } - } - - // Recursively traverse properties - if (obj.properties) { - for (const [key, value] of Object.entries(obj.properties)) { - if (value && typeof value === 'object') { - // Use the key as field name if we're inside a FormField - const fieldName = obj['x-component'] === 'FormField' ? key : currentFieldName; - traverse(value, fieldName); - } - } - } - } - - traverse(schema); - return requiredFields; -} \ No newline at end of file