Skip to content

Commit

Permalink
feat: support simple custom field via is attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
14nrv committed Mar 30, 2021
1 parent 49644f3 commit d84bb5a
Show file tree
Hide file tree
Showing 8 changed files with 311 additions and 103 deletions.
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,16 @@ Once submitted, an event 'formSubmitted' is emitted on $root with the formName a
```js
const formFields = [{ slot: 'nameOfTheSlot', props: { foo: 'bar' } }]
```
* [**Custom fields support**](https://codesandbox.io/s/vue-form-json-demo-dgk2n?file=/src/App.vue) inside scoped slot
```html
<template #nameOfTheSlot="{ foo, updateFormValues, isFormReseted }">
* **Custom fields support**
* for a simple field (with `is` attribute + `components` prop)
```js
const formFields = [{ is: 'CustomFieldName' }]
```

* [inside a scoped slot](https://codesandbox.io/s/vue-form-json-demo-dgk2n?file=/src/App.vue) for more flexibility
```html
<template #nameOfTheSlot="{ foo, updateFormValues, isFormReseted }">
```
* **Html support**
```js
const formFields = [{ html: '<p>Your html content</p>' }]
Expand Down Expand Up @@ -159,6 +165,10 @@ props: {
camelizePayloadKeys: {
type: Boolean,
default: false
}
},
components: {
type: Object,
default: () => ({})
},
}
```
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ module.exports = {
'**/*.spec.(js|jsx|ts|tsx)'
],
testURL: 'http://localhost/',
reporters: process.env.CI ? ['default', 'jest-junit'] : undefined
reporters: process.env.CI ? ['default', 'jest-junit'] : undefined,
watchPathIgnorePatterns: [
'/node_modules/'
]
}
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,12 @@
"form-generator",
"form-json",
"form",
"json-schema-form",
"schema",
"vue-json-form",
"vue",
"vue.js",
"vuejs",
"generator",
"validation",
"form-validation",
"bulma"
],
"repository": {
Expand Down
31 changes: 23 additions & 8 deletions src/components/Fields/Control.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
tag="div"
:vid="item.vid"
:rules="getRules"
:name="item.name || item.label | slugify"
:name="getName"
:immediate="!!item.value"
v-slot="{ errors, ariaInput }")

.control(:class="{'has-icons-left': item.iconLeft, 'has-icons-right': shouldShowErrorIcon}")
component(v-model.lazy.trim="value",
:is="`app-${getComponent}`"
v-bind="getDynamicComponentAttrs"
:item="item",
:is="dynamicComponent || `app-${getComponent}`",
:error="errors[0]",
:ariaInput="ariaInput")

Expand Down Expand Up @@ -38,9 +39,6 @@ const NOT_NORMAL_INPUT = [
export default {
name: 'Control',
filters: {
slugify: value => slug(value)
},
components: {
appCheckbox: Checkbox,
appInput: Input,
Expand All @@ -49,6 +47,10 @@ export default {
appTextarea: Textarea
},
props: {
dynamicComponent: {
type: [String, Object, Function],
default: undefined
},
item: {
type: Object,
required: true
Expand All @@ -65,14 +67,27 @@ export default {
return this.$children[0].errors[0]
},
shouldShowErrorIcon () {
return this.fieldError && this.item.type !== 'select' && this.hasIcon
return this.fieldError &&
this.item.type !== 'select' &&
this.hasIcon &&
!this.dynamicComponent
},
isNormalInput () {
return !NOT_NORMAL_INPUT.includes(this.item.type)
},
getName () {
return this.item.name ||
(this.item.label && slug(this.item.label)) ||
this.item.attr.name ||
this.item.is
},
getComponent () {
return this.isNormalInput ? 'input' : this.item.type
},
getDynamicComponentAttrs () {
const { is, attr } = this.item
return is && attr ? attr : {}
},
getRules () {
const { rules = {}, pattern } = this.item
rules.required = this.item.isRequired !== false
Expand All @@ -84,8 +99,8 @@ export default {
},
watch: {
value (val) {
const { label, name } = this.item
this.$parent.$parent.formValues[name || label] = val
const { label, name, is } = this.item
this.$parent.$parent.formValues[name || label || is] = val
}
}
}
Expand Down
84 changes: 0 additions & 84 deletions src/components/Form/Form.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,90 +218,6 @@ describe('Form', () => {
expect($errorMessage).not.toBeADomElement()
})

describe('slot', () => {
const slotContainer = '[data-test=slot]'

it('has no slot by default', async () => {
setup({ formFields: [{ label: 'superLabel' }] })

expect(slotContainer).not.toBeADomElement()
})

it('has a scoped slot', () => {
setup()
expect(slotContainer).toBeADomElement()

const allSlots = wrapper.findAll(slotContainer)
expect(allSlots).toHaveLength(1)

const { props: { prop } } = fields.find(field => 'slot' in field)
expect(allSlots.at(0).text()).toBe(prop)
})

const formFields = [
{
slot: 'customField',
props: {
placeholder: 'the placeholder'
}
},
{
label: 'normalInput'
},
{
label: 'notRequiredInput',
isRequired: false
}
]

const scopedSlots = {
customField: `
<ValidationProvider rules="required" name="fieldInSlot">
<input
name="fieldInSlot"
type="text"
@input.prevent="props.updateFormValues({
customField: $event.target.value
})"
:placeholder="props.placeholder"
/>
</ValidationProvider>`
}
it('handles a field in a scoped slot', async () => {
const { wrapper } = setup({ formFields, scopedSlots })
const rootWrapper = createWrapper(wrapper.vm.$root)
wrapper.vm.$refs.observer.validate = jest.fn(() => true)
await flush()

const $normalInput = 'input[name=normalinput]'
const $fieldInSlot = 'input[name=fieldInSlot]'
const fieldInSlotValue = 'value for field in slot'
const normalInputValue = 'a value'

expect($fieldInSlot).toHaveAttribute('placeholder', formFields[0].props.placeholder)

expect($inputSubmit).toHaveAttribute('disabled', 'disabled')

type(fieldInSlotValue, $fieldInSlot)
type(normalInputValue, $normalInput)
await flush()

expect($inputSubmit).toHaveAttribute('disabled', undefined)

trigger($inputSubmit, 'submit')
await flush()

expect(rootWrapper).toEmitWith('formSubmitted', {
formName: FORM_NAME,
values: {
customField: fieldInSlotValue,
normalInput: normalInputValue,
notRequiredInput: undefined
}
})
})
})

describe('default value', () => {
it('set default value on radio', async () => {
const radioField = {
Expand Down
15 changes: 12 additions & 3 deletions src/components/Form/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@
@reset.prevent="handleReset")

div(v-for="(item, index) in formFields", :key="index")
.field-body(v-if="Array.isArray(item)")
.field(v-if="'is' in item")
app-control(:item="item",
ref="control",
:dynamicComponent="components[item.is]")

.field-body(v-else-if="Array.isArray(item)")
.field(v-for="x in item",
:key="x.label",
v-bind="x.field && x.field.attr")
app-label(:item="x")
app-control(:item="x", ref="control")

.field(v-else-if="Object.keys(item).includes('html')",
.field(v-else-if="'html' in item",
v-html="Object.values(item)[0]",
v-bind="item.attr",
data-test="htmlContentFromFormFields")

.field(v-else-if="Object.keys(item).includes('slot')",
.field(v-else-if="'slot' in item",
v-bind="item.attr",
data-test="slot")
slot(
Expand Down Expand Up @@ -76,6 +81,10 @@ export default {
type: Array,
required: true
},
components: {
type: Object,
default: () => ({})
},
formName: {
type: String,
required: true
Expand Down
125 changes: 125 additions & 0 deletions src/components/Form/FormComponent.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import matchers from 'jest-vue-matcher'
import { mount, createLocalVue, createWrapper } from '@vue/test-utils'
import { slug } from '@/helpers'
import { extendRules, flush } from '@/helpers/test'
import { ValidationProvider } from 'vee-validate'
import Form from '@/components/Form'
import Vue from 'vue'

const fields = [
{
is: 'CustomField',
attr: {
placeholder: 'the placeholder',
name: 'customField'
}
}
]

extendRules()

const localVue = createLocalVue()
localVue.component('ValidationProvider', ValidationProvider)
localVue.filter('slugify', str => slug(str))

const FORM_NAME = 'testFormName'
const $inputSubmit = 'input[type=submit]'
const $customField = 'input[name=customField]'

const type = (text, input, event = 'input') => {
const node = wrapper.find(input)
node.setValue(text)
node.trigger(event)
}

let wrapper

const CustomField = Vue.component('CustomField', {
props: {
item: {
type: Object,
required: true
}
},
mounted () {
this.item.value && (this.$emit('input', this.item.value))
},
methods: {
handleClick (ev) {
this.$emit('input', ev.target.value)
}
},
render (h) {
return <input vOn:input={ this.handleClick } />
}
})

const setup = ({
formFields = fields
} = {}) => {
wrapper = mount(Form, {
localVue,
propsData: {
camelizePayloadKeys: false,
formFields,
formName: FORM_NAME,
components: { CustomField }
}
})
expect.extend(matchers(wrapper))
return { wrapper }
}

describe('dynamic component', () => {
it('has a field', async () => {
setup()

expect($customField).toBeADomElement()
})

it('binds attrs', () => {
setup()

expect($customField).toHaveAttribute('placeholder', 'the placeholder')
expect($customField).toHaveAttribute('name', 'customField')
})

it('handles rules', async () => {
setup({
formFields: [{
...fields[0],
rules: {
is_not: 'test'
},
value: 'test'
}]
})
await flush()

expect('p.is-danger').toHaveText('customField is not valid.')
})

it('handles dynamic field passed as component props', async () => {
const { wrapper } = setup()
const rootWrapper = createWrapper(wrapper.vm.$root)
wrapper.vm.$refs.observer.validate = jest.fn(() => true)
await flush()

expect($inputSubmit).toHaveAttribute('disabled', 'disabled')

type('test', $customField)
await flush()

expect($inputSubmit).toHaveAttribute('disabled', undefined)

wrapper.find($inputSubmit).trigger('submit')
await flush()

expect(rootWrapper).toEmitWith('formSubmitted', {
formName: FORM_NAME,
values: {
CustomField: 'test'
}
})
})
})

0 comments on commit d84bb5a

Please sign in to comment.