diff --git a/apps/docs/.vitepress/config.js b/apps/docs/.vitepress/config.js
index e7152b2..3ce522b 100644
--- a/apps/docs/.vitepress/config.js
+++ b/apps/docs/.vitepress/config.js
@@ -58,6 +58,7 @@ export default defineConfig({
{ text: 'FieldColor', link: '/guide/fields/FieldColor' },
{ text: 'FieldMask', link: '/guide/fields/FieldMask' },
{ text: 'FieldNumber', link: '/guide/fields/FieldNumber' },
+ { text: 'FieldObject', link: '/guide/fields/FieldObject' },
{ text: 'FieldPassword', link: '/guide/fields/FieldPassword' },
{ text: 'FieldRadio', link: '/guide/fields/FieldRadio' },
{ text: 'FieldReset', link: '/guide/fields/FieldReset' },
diff --git a/apps/docs/components/examples/fields/FieldObjectExample.vue b/apps/docs/components/examples/fields/FieldObjectExample.vue
new file mode 100644
index 0000000..d59116e
--- /dev/null
+++ b/apps/docs/components/examples/fields/FieldObjectExample.vue
@@ -0,0 +1,63 @@
+
+ Person: {{ form.model.person }}
+
+
+
+
\ No newline at end of file
diff --git a/apps/docs/guide/fields/FieldObject.md b/apps/docs/guide/fields/FieldObject.md
new file mode 100644
index 0000000..d3c1c24
--- /dev/null
+++ b/apps/docs/guide/fields/FieldObject.md
@@ -0,0 +1,112 @@
+# FieldObject
+`FieldObject` is a field that has its own `schema`, meaning the field itself
+renders other fields. These fields will return their values to the object inside
+the model that is assigned to the `FieldObject` component.
+
+### type `object`
+
+
+
+## Basic example
+::: details Code
+```js
+const form = ref({
+ model: {
+ person: {
+ name: '',
+ surname: '',
+ age: null
+ }
+ },
+ schema: {
+ fields: [
+ {
+ type: 'object',
+ model: 'person',
+ schema: {
+ fields: [
+ {
+ type: 'input',
+ inputType: 'text',
+ model: 'name',
+ label: 'Name'
+ },
+ {
+ type: 'input',
+ inputType: 'text',
+ model: 'surname',
+ label: 'Surname'
+ },
+ {
+ type: 'input',
+ inputType: 'number',
+ model: 'age',
+ label: 'Age'
+ }
+ ]
+ }
+ }
+ ]
+ }
+})
+```
+:::
+
+
+## With validators
+::: details Code
+```js
+function minLengthThree (value) {
+ return value && value.length >= 3
+}
+
+function overEighteen (value) {
+ return value && value >= 18
+}
+
+// ......
+fields: [
+ {
+ type: 'object',
+ model: 'person',
+ schema: {
+ fields: [
+ {
+ type: 'input',
+ inputType: 'text',
+ model: 'name',
+ label: 'Name',
+ validator: minLengthThree
+ },
+ {
+ type: 'input',
+ inputType: 'text',
+ model: 'surname',
+ label: 'Surname',
+ validator: minLengthThree
+ },
+ {
+ type: 'input',
+ inputType: 'number',
+ model: 'age',
+ label: 'Age',
+ validator: overEighteen
+ }
+ ]
+ }
+ }
+ ]
+```
+:::
+::: info
+In this example, `name` and `surname` must have a length of three letters or more, `age` must be at least 18.
+:::
+
+
+
+## Properties
+| Property | Default | Type | Description |
+|----------|---------|----------|---------------------------------------------|
+| schema | `{}` | `Object` | A form schema, as seen in `FormGenerator.vue` |
\ No newline at end of file
diff --git a/apps/docs/guide/form-generator/events.md b/apps/docs/guide/form-generator/events.md
index 7971642..239031d 100644
--- a/apps/docs/guide/form-generator/events.md
+++ b/apps/docs/guide/form-generator/events.md
@@ -1,4 +1,27 @@
These are events emitted by the `vue-form-generator` component.
+## `field-validated`
+Emitted when a field inside the form has been validated.
+
+### Event arguments
+- `validations` - an object with the field's errors and the field schema
+ - `fieldErrors` - an array of error messages that have been thrown during validations;
+ - `field` - the field as defined in the schema
+
+An example from the [`FieldObject`](/guide/fields/FieldObject) component:
+```vue [FieldObject.vue]
+
+```
+
## `submit`
-Emitted when all fields have been validated and no errors occurred during said validations.
\ No newline at end of file
+Emitted when all fields have been validated and no errors occurred during said validations.
diff --git a/package.json b/package.json
index de122c7..718c3eb 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"main": "./dist/vue3-form-generator.js",
"scripts": {
"dev": "vite",
- "dev:sass": "sass --watch src/scss/themes:playground/css/",
+ "dev:sass": "sass --watch src/scss/themes:apps/playground/css/",
"test": "vitest",
"build": "vite build && sass src/scss/themes/:dist/themes/",
"preview": "vite preview",
diff --git a/src/FormGenerator.vue b/src/FormGenerator.vue
index 4b99637..333c5f3 100644
--- a/src/FormGenerator.vue
+++ b/src/FormGenerator.vue
@@ -3,7 +3,7 @@ import { computed, ref } from 'vue'
import { resetObjectProperties, toUniqueArray } from '@/helpers'
import FormGroup from './FormGroup.vue'
-const emits = defineEmits([ 'submit' ])
+const emits = defineEmits([ 'submit', 'field-validated' ])
const props = defineProps({
id: {
@@ -55,6 +55,7 @@ const updateGeneratorModel = ({ model, value }) => {
* @param field field schema object that has been validated.
*/
const onFieldValidated = ({ fieldErrors, field }) => {
+ emits('field-validated', { fieldErrors, field })
if (!fieldErrors.length) {
if (!(field.model in formErrors.value)) return
else {
@@ -82,7 +83,7 @@ const onReset = () => {
props.model = resetObjectProperties(props.model)
}
-defineExpose({ hasErrors })
+defineExpose({ hasErrors, formErrors })
diff --git a/src/fields/core/FieldObject.vue b/src/fields/core/FieldObject.vue
new file mode 100644
index 0000000..473718b
--- /dev/null
+++ b/src/fields/core/FieldObject.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/fields/index.ts b/src/fields/index.ts
index c6122dc..96f018a 100644
--- a/src/fields/index.ts
+++ b/src/fields/index.ts
@@ -13,6 +13,7 @@ import FieldTextarea from '@/fields/core/FieldTextarea.vue'
import FieldMask from '@/fields/core/FieldMask.vue'
import FieldChecklist from '@/fields/core/FieldChecklist.vue'
import FieldCheckbox from '@/fields/core/FieldCheckbox.vue'
+import FieldObject from '@/fields/core/FieldObject.vue'
import FieldSubmit from '@/fields/core/FieldSubmit.vue'
import FieldReset from '@/fields/core/FieldReset.vue'
@@ -22,7 +23,7 @@ import FieldButton from '@/fields/core/FieldButton.vue'
const fieldComponents = {
FieldColor, FieldText, FieldCheckBox, FieldPassword, FieldSelect, FieldSelectNative, FieldRadio,
FieldNumber, FieldSubmit, FieldReset, FieldButton, FieldSwitch, FieldTextarea, FieldMask, FieldChecklist,
- FieldCheckbox
+ FieldCheckbox, FieldObject
} as const
type FieldComponentNames = keyof typeof fieldComponents
diff --git a/tests/_resources/utils.js b/tests/_resources/utils.js
index 2eafe42..d20fdcc 100644
--- a/tests/_resources/utils.js
+++ b/tests/_resources/utils.js
@@ -49,12 +49,12 @@ export function generateSchemaSingleField (
/**
* Generate props for a single field component
* @param {Object} formSchema - entire form schema object
- * @returns {{field: *, model, id: string, formGenerator: {}}}
+ * @returns {{field: *, model, id: string, formOptions: {}}}
*/
export function generatePropsSingleField (formSchema) {
return {
id: formSchema.name + '_test_id',
- formGenerator: {},
+ formOptions: {},
field: { ...formSchema.schema.fields[0] },
model: { ...formSchema.model }
}
diff --git a/tests/components/FormGenerator.spec.js b/tests/components/FormGenerator.spec.js
new file mode 100644
index 0000000..ff378d2
--- /dev/null
+++ b/tests/components/FormGenerator.spec.js
@@ -0,0 +1,55 @@
+import { expect, it, describe, beforeAll } from 'vitest'
+import { config, mount } from '@vue/test-utils'
+
+import FormGenerator from '@/FormGenerator.vue'
+import FieldText from '@/fields/core/FieldText.vue'
+import FieldTextarea from '@/fields/core/FieldTextarea.vue'
+import FieldSubmit from '@/fields/core/FieldSubmit.vue'
+import { generateSchemaSingleField } from '@test/_resources/utils.js'
+
+beforeAll(() => {
+ config.global.components = { FieldText, FieldTextarea, FieldSubmit }
+})
+
+const textSchema = generateSchemaSingleField(
+ 'text',
+ 'textModel',
+ 'input',
+ 'text',
+ 'Text input label',
+ '',
+ {
+ required: true
+ }
+)
+
+const textAreaSchema = generateSchemaSingleField(
+ 'textArea',
+ 'textAreaModel',
+ 'textarea',
+ null,
+ 'Text area label',
+ '',
+ {}
+)
+
+const schema = {
+ schema: { fields: [ ...textSchema.schema.fields, ...textAreaSchema.schema.fields ] },
+ model: { ...textSchema.model, ...textAreaSchema.model }
+}
+
+describe('FormGenerator', () => {
+
+ it('Shouldn\'t render without a schema', async () => {
+ const wrapper = mount(FormGenerator)
+ expect(wrapper.find('form').exists()).toBeFalsy()
+ })
+
+ it('Should render with a schema', async () => {
+ const wrapper = mount(FormGenerator, { props: { model: schema.model, schema: schema.schema } })
+ expect(wrapper.find('form').exists()).toBeTruthy()
+ expect(wrapper.findComponent(FieldText).exists()).toBeTruthy()
+ expect(wrapper.findComponent(FieldTextarea).exists()).toBeTruthy()
+ })
+
+})
\ No newline at end of file
diff --git a/tests/components/fields/FieldObject.spec.js b/tests/components/fields/FieldObject.spec.js
new file mode 100644
index 0000000..a28e0c2
--- /dev/null
+++ b/tests/components/fields/FieldObject.spec.js
@@ -0,0 +1,119 @@
+import { generatePropsSingleField, mountFormGenerator } from '@test/_resources/utils.js'
+import { expect, it, describe, beforeAll } from 'vitest'
+import { config, mount } from '@vue/test-utils'
+
+import FieldObject from '@/fields/core/FieldObject.vue'
+import FieldNumber from '@/fields/core/FieldNumber.vue'
+import FieldText from '@/fields/core/FieldText.vue'
+import FormGenerator from '@/FormGenerator.vue'
+
+beforeAll(() => {
+ config.global.components = { FieldObject, FieldNumber, FieldText }
+})
+
+const shouldBeOverEighteen = (value) => value && value >= 18
+
+const form = {
+ model: {
+ person: {
+ name: '',
+ age: null
+ }
+ },
+ schema: {
+ fields: [
+ {
+ type: 'object',
+ model: 'person',
+ schema: {
+ fields: [
+ {
+ type: 'text',
+ name: 'name',
+ model: 'name',
+ label: 'Full name'
+ },
+ {
+ type: 'number',
+ name: 'age',
+ model: 'age',
+ label: 'Age',
+ validator: [ shouldBeOverEighteen ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
+
+const props = generatePropsSingleField(form)
+
+describe('FieldObject', () => {
+
+ // For rendering, we'll only need to test if the components are actually present within the Field or FormGenerator.
+ // This is because each Field will have their own individual tests for checking if it renders correctly.
+ it('Should render properly', async () => {
+ const wrapper = mount(FieldObject, { props })
+ // Since the FieldObject basically renders a form inside the form, this component should be there
+ expect(wrapper.findComponent(FormGenerator)).toBeTruthy()
+ expect(wrapper.findComponent(FieldNumber)).toBeTruthy()
+ expect(wrapper.findComponent(FieldText)).toBeTruthy()
+ })
+
+ it('Should render properly inside form generator', async () => {
+ const wrapper = mountFormGenerator(form.schema, form.model)
+ // Since the FieldObject basically renders a form inside the form, this component should be there
+ expect(wrapper.findComponent(FormGenerator)).toBeTruthy()
+ expect(wrapper.findComponent(FieldNumber)).toBeTruthy()
+ expect(wrapper.findComponent(FieldText)).toBeTruthy()
+ })
+
+ it('Should properly update model value', async () => {
+ const wrapper = mountFormGenerator(form.schema, form.model)
+ expect(typeof wrapper.vm.model.person).toBe('object')
+
+ const fieldWrapper = wrapper.findComponent(FieldObject)
+ fieldWrapper.find('input[type=number]').setValue(21)
+ // Both values should match.
+ expect(fieldWrapper.vm.model.person.age).toBe(21)
+ expect(wrapper.vm.model.person.age).toBe(21)
+
+ fieldWrapper.find('input[type=text]').setValue('Test subject')
+ // Both values should match.
+ expect(fieldWrapper.vm.model.person.name).toBe('Test subject')
+ expect(wrapper.vm.model.person.name).toBe('Test subject')
+ })
+
+ it('Should properly pass errors to parent form', async () => {
+ const wrapper = mountFormGenerator(form.schema, form.model)
+ const fieldWrapper = wrapper.findComponent(FieldObject)
+
+ const ageInput = fieldWrapper.find('input[type=number]')
+ ageInput.setValue(15)
+ await ageInput.trigger('blur')
+ expect(fieldWrapper.emitted()).toHaveProperty('validated')
+ // Default models shouldn't show up in the main form, as the model is an object.
+ expect(wrapper.vm.formErrors).not.toHaveProperty('age')
+ expect(wrapper.vm.formErrors).not.toHaveProperty('person')
+ // The only correct way
+ expect(wrapper.vm.formErrors).toHaveProperty('person.age')
+ expect(wrapper.vm.formErrors['person.age']).toHaveLength(1)
+ expect(wrapper.vm.formErrors['person.age'][0]).toBe('Field is invalid')
+ })
+
+ it('Should remove old errors', async () => {
+ const wrapper = mountFormGenerator(form.schema, form.model)
+ const fieldWrapper = wrapper.findComponent(FieldObject)
+
+ const ageInput = fieldWrapper.find('input[type=number]')
+ await ageInput.setValue(15)
+ await ageInput.trigger('blur')
+ expect(wrapper.vm.formErrors).toHaveProperty('person.age')
+
+ await ageInput.setValue(18)
+ await ageInput.trigger('blur')
+ expect(wrapper.vm.formErrors).not.toHaveProperty('person.age')
+ })
+
+})