Skip to content

Commit

Permalink
Merge pull request #5017 from manuelmeister/bugfix/number-input
Browse files Browse the repository at this point in the history
Create number field
  • Loading branch information
manuelmeister authored Apr 28, 2024
2 parents 06ea41e + 2dfad2a commit acd62a5
Show file tree
Hide file tree
Showing 18 changed files with 761 additions and 203 deletions.
55 changes: 55 additions & 0 deletions frontend/src/components/form/api/ApiNumberField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!--
Displays a field as a e-number-field + write access via API wrapper
-->

<template>
<api-wrapper v-slot="wrapper" v-bind="$props" v-on="$listeners">
<e-number-field
ref="textField"
:value="wrapper.localValue"
v-bind="$attrs"
:path="path"
:readonly="wrapper.readonly"
:disabled="disabled"
:error-messages="wrapper.errorMessages"
:loading="wrapper.isSaving || wrapper.isLoading ? 'secondary' : false"
:outlined="outlined"
:filled="filled"
:dense="dense"
@input="wrapper.on.input"
@blur="wrapper.on.blur"
>
<template #append>
<api-wrapper-append :wrapper="wrapper" />
</template>
</e-number-field>
</api-wrapper>
</template>

<script>
import { apiPropsMixin } from '@/mixins/apiPropsMixin.js'
import ApiWrapper from './ApiWrapper.vue'
import ApiWrapperAppend from './ApiWrapperAppend.vue'
export default {
name: 'ApiNumberField',
components: { ApiWrapper, ApiWrapperAppend },
mixins: [apiPropsMixin],
props: {
outlined: {
type: Boolean,
default: true,
},
},
data() {
return {}
},
methods: {
focus() {
this.$refs.textField.focus()
},
},
}
</script>

<style scoped></style>
12 changes: 1 addition & 11 deletions frontend/src/components/form/api/ApiTextField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Displays a field as a e-text-field + write access via API wrapper
-->

<template>
<api-wrapper v-slot="wrapper" :parse="parse" v-bind="$props" v-on="$listeners">
<api-wrapper v-slot="wrapper" v-bind="$props" v-on="$listeners">
<e-text-field
ref="textField"
:value="wrapper.localValue"
Expand Down Expand Up @@ -48,16 +48,6 @@ export default {
focus() {
this.$refs.textField.focus()
},
parse(input) {
if (
(this.$attrs.inputmode === 'numeric' || this.$attrs.type === 'number') &&
!Number.isNaN(Number(input))
) {
return Number(input)
} else {
return input
}
},
},
}
</script>
Expand Down
23 changes: 13 additions & 10 deletions frontend/src/components/form/api/ApiWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,20 @@ export default {
},
},
watch: {
apiValue: function (newValue) {
// override local value if it wasn't dirty
if (!this.dirty || this.overrideDirty) {
this.localValue = newValue
this.parsedLocalValue = this.parse ? this.parse(newValue) : newValue
}
apiValue: {
handler: function (newValue) {
// override local value if it wasn't dirty
if (!this.dirty || this.overrideDirty) {
this.localValue = newValue
this.parsedLocalValue = this.parse ? this.parse(newValue) : newValue
}
// clear dirty if outside value changes to same as local value (e.g. after save operation)
if (this.parsedLocalValue === newValue) {
this.dirty = false
}
// clear dirty if outside value changes to same as local value (e.g. after save operation)
if (this.parsedLocalValue === newValue) {
this.dirty = false
}
},
immediate: true,
},
},
created() {
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/form/api/__tests__/ApiNumberField.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import ApiNumberField from '../ApiNumberField.vue'
import ApiWrapper from '@/components/form/api/ApiWrapper.vue'
import Vue from 'vue'
import Vuetify from 'vuetify'
import flushPromises from 'flush-promises'
import formBaseComponents from '@/plugins/formBaseComponents'
import merge from 'lodash/merge'
import { ApiMock } from '@/components/form/api/__tests__/ApiMock'
import { i18n } from '@/plugins'
import { mount as mountComponent } from '@vue/test-utils'
import { waitForDebounce } from '@/test/util'

Vue.use(Vuetify)
Vue.use(formBaseComponents)

describe('An ApiNumberField', () => {
let vuetify
let wrapper
let apiMock

const path = 'test-field/123'
const NUMBER_1 = 1.2
const NUMBER_1_string = '1.2'

beforeEach(() => {
vuetify = new Vuetify()
apiMock = ApiMock.create()
})

afterEach(() => {
jest.restoreAllMocks()
wrapper.destroy()
})

const mount = (options) => {
const app = Vue.component('App', {
components: { ApiNumberField },
props: {
path: { type: String, default: path },
},
template: `<div data-app>
<api-number-field
:auto-save="false"
:path="path"
uri="test-field/123"
label="Test field"
required="true"
/>
</div>`,
})
apiMock.get().thenReturn(ApiMock.success(NUMBER_1).forPath(path))
const defaultOptions = {
mocks: {
$tc: () => {},
api: apiMock.getMocks(),
},
}
return mountComponent(app, {
vuetify,
i18n,
attachTo: document.body,
...merge(defaultOptions, options),
})
}

test('triggers api.patch and status update if input changes', async () => {
apiMock.patch().thenReturn(ApiMock.success(NUMBER_1))
wrapper = mount()

await flushPromises()

const input = wrapper.find('input')
await input.setValue(NUMBER_1)
await input.trigger('submit')

await waitForDebounce()
await flushPromises()

expect(apiMock.getMocks().patch).toBeCalledTimes(1)
expect(wrapper.findComponent(ApiWrapper).vm.parsedLocalValue).toBe(NUMBER_1)
})

test('updates state if value in store is refreshed and has new value', async () => {
wrapper = mount()
apiMock.get().thenReturn(ApiMock.success(NUMBER_1).forPath(path))

wrapper.findComponent(ApiWrapper).vm.reload()

await waitForDebounce()
await flushPromises()

expect(wrapper.findComponent(ApiWrapper).vm.parsedLocalValue).toBe(NUMBER_1)
expect(wrapper.find('input[type=text]').element.value).toBe(NUMBER_1_string)
})
})
49 changes: 2 additions & 47 deletions frontend/src/components/form/api/__tests__/ApiTextField.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ describe('An ApiTextField', () => {
const path = 'test-field/123'
const TEXT_1 = 'some text'
const TEXT_2 = 'another text'
const NUMBER_1 = 1.2
const NUMBER_1_string = '1.2'

beforeEach(() => {
vuetify = new Vuetify()
Expand All @@ -34,24 +32,13 @@ describe('An ApiTextField', () => {
wrapper.destroy()
})

const mount = (options, number = false) => {
const mount = (options) => {
const app = Vue.component('App', {
components: { ApiTextField },
props: {
path: { type: String, default: path },
},
template: number
? `<div data-app>
<api-text-field
:auto-save="false"
:path="path"
uri="test-field/123"
label="Test field"
required="true"
inputmode="numeric"
/>
</div>`
: `<div data-app>
template: `<div data-app>
<api-text-field
:auto-save="false"
:path="path"
Expand Down Expand Up @@ -107,36 +94,4 @@ describe('An ApiTextField', () => {
expect(wrapper.find('input[type=text]').element.value).toBe(TEXT_2)
})
})

describe('number', () => {
test('triggers api.patch and status update if input changes', async () => {
apiMock.patch().thenReturn(ApiMock.success(NUMBER_1))
wrapper = mount({}, true)

await flushPromises()

const input = wrapper.find('input')
await input.setValue(NUMBER_1)
await input.trigger('submit')

await waitForDebounce()
await flushPromises()

expect(apiMock.getMocks().patch).toBeCalledTimes(1)
expect(wrapper.findComponent(ApiWrapper).vm.parsedLocalValue).toBe(NUMBER_1)
})

test('updates state if value in store is refreshed and has new value', async () => {
wrapper = mount({}, true)
apiMock.get().thenReturn(ApiMock.success(NUMBER_1).forPath(path))

wrapper.findComponent(ApiWrapper).vm.reload()

await waitForDebounce()
await flushPromises()

expect(wrapper.findComponent(ApiWrapper).vm.parsedLocalValue).toBe(NUMBER_1)
expect(wrapper.find('input[type=text]').element.value).toBe(NUMBER_1_string)
})
})
})
72 changes: 72 additions & 0 deletions frontend/src/components/form/base/ENumberField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<template>
<EParseField
ref="input"
:value="value"
:format="format"
:parse="parse"
:input-filter="inputFilter"
:required="required"
:vee-id="veeId"
:vee-rules="veeRules"
reset-on-blur
v-bind="$attrs"
@input="$emit('input', $event)"
>
<!-- passing through all slots -->
<slot v-for="(_, name) in $slots" :slot="name" :name="name" />
<template #scoped="{ scopedSlots }">
<template v-for="(_, name) in scopedSlots" :slot="name" slot-scope="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</template>
</EParseField>
</template>

<script>
import { formComponentMixin } from '@/mixins/formComponentMixin.js'
export default {
name: 'ENumberField',
mixins: [formComponentMixin],
props: {
value: { type: [String, Number], required: false, default: null },
},
emits: ['input'],
methods: {
format(value) {
switch (value) {
case null:
return ''
default:
return value + ''
}
},
inputFilter(value) {
if (/\d/.test(value) && value.match(/^[^,]*,[^,.]+$/g)) {
value = value.replace(/\./g, '').replace(/,/g, '.')
}
// Remove all dots except the first one
let firstDotFound = false
value = value.replace(/\./g, (match) =>
firstDotFound ? '' : (firstDotFound = match)
)
// Remove everything except numbers, dots and the first minus sign
const negative = value.startsWith('-')
value = value.replace(/[^0-9.]/g, '')
value = negative ? '-' + value : value
return value
},
/**
* @param {string} value
*/
parse(value) {
return isNaN(parseFloat(value)) || /^\.0*$/.test(value) ? null : parseFloat(value)
},
focus() {
this.$refs.input.focus()
},
},
}
</script>
Loading

0 comments on commit acd62a5

Please sign in to comment.