Skip to content

Commit

Permalink
Merge pull request #5174 from manuelmeister/fix/null-colorpicker
Browse files Browse the repository at this point in the history
Create new color picker & color field
  • Loading branch information
manuelmeister authored Jun 15, 2024
2 parents 66437f4 + ff7c250 commit ea9e0a8
Show file tree
Hide file tree
Showing 22 changed files with 938 additions and 280 deletions.
44 changes: 44 additions & 0 deletions frontend/src/components/form/api/ApiColorField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<!--
Displays a field as a e-color-field + write access via API wrapper
-->

<template>
<api-wrapper v-slot="wrapper" v-bind="$props" separate-buttons v-on="$listeners">
<e-color-field
: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-color-field>
</api-wrapper>
</template>

<script>
import { apiPropsMixin } from '@/mixins/apiPropsMixin.js'
import ApiWrapper from './ApiWrapper.vue'
import ApiWrapperAppend from './ApiWrapperAppend.vue'
export default {
name: 'ApiColorField',
components: { ApiWrapper, ApiWrapperAppend },
mixins: [apiPropsMixin],
data() {
return {}
},
}
</script>

<style scoped></style>
92 changes: 92 additions & 0 deletions frontend/src/components/form/api/__tests__/ApiColorField.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import ApiColorField from '../ApiColorField.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { render } from '@/test/renderWithVuetify.js'
import user from '@testing-library/user-event'
import { ApiMock } from '@/components/form/api/__tests__/ApiMock'
import { extend } from 'vee-validate'
import { regex } from 'vee-validate/dist/rules'
import { ColorSpace, sRGB } from 'colorjs.io/fn'

extend('regex', regex)

ColorSpace.register(sRGB)
describe('An ApiColorField', () => {
let apiMock

const FIELD_PATH = 'test-field/123'
const FIELD_LABEL = 'Test field'
const COLOR_1 = '#FF0000'
const COLOR_2 = '#FAFFAF'

beforeEach(() => {
apiMock = ApiMock.create()
})

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

test('triggers api.patch and status update if input changes', async () => {
// given
apiMock.get().thenReturn(ApiMock.success(COLOR_1).forPath(FIELD_PATH))
apiMock.patch().thenReturn(ApiMock.success(COLOR_2))
render(ApiColorField, {
props: {
autoSave: false,
path: FIELD_PATH,
uri: 'test-field/123',
label: FIELD_LABEL,
required: true,
},
mocks: {
api: apiMock.getMocks(),
},
})

// when
const inputField = await screen.findByLabelText(FIELD_LABEL)
inputField.value = COLOR_2
await fireEvent.input(inputField)
// click the button to open the picker
// click the save button
await waitFor(async () => {
await user.click(screen.getByLabelText('Speichern'))
})

// then
await waitFor(async () => {
const inputField = await screen.findByLabelText(FIELD_LABEL)
expect(inputField.value).toBe(COLOR_2)
expect(apiMock.getMocks().patch).toBeCalledTimes(1)
})
})

test('updates state if value in store is refreshed and has new value', async () => {
// given
apiMock.get().thenReturn(ApiMock.networkError().forPath(FIELD_PATH))
render(ApiColorField, {
props: {
autoSave: false,
path: FIELD_PATH,
uri: 'test-field/123',
label: FIELD_LABEL,
required: true,
},
mocks: {
api: apiMock.getMocks(),
},
})
await screen.findByText('A network error occurred.')
expect((await screen.findByLabelText(FIELD_LABEL)).value).not.toBe(COLOR_1)
const retryButton = await screen.findByText('Erneut versuchen')
apiMock.get().thenReturn(ApiMock.success(COLOR_1).forPath(FIELD_PATH))

// when
await user.click(retryButton)

// then
await waitFor(async () => {
expect((await screen.findByLabelText(FIELD_LABEL)).value).toBe(COLOR_1)
})
})
})
14 changes: 10 additions & 4 deletions frontend/src/components/form/api/__tests__/ApiColorPicker.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import user from '@testing-library/user-event'
import { ApiMock } from '@/components/form/api/__tests__/ApiMock'
import { extend } from 'vee-validate'
import { regex } from 'vee-validate/dist/rules'
import { ColorSpace, sRGB } from 'colorjs.io/fn'

extend('regex', regex)

ColorSpace.register(sRGB)
describe('An ApiColorPicker', () => {
let apiMock

Expand Down Expand Up @@ -49,12 +51,16 @@ describe('An ApiColorPicker', () => {
const canvas = container.querySelector('canvas')
await user.click(canvas, { clientX: 10, clientY: 10 })
// click the save button
await user.click(screen.getByLabelText('Speichern'))
await waitFor(async () => {
await user.click(screen.getByLabelText('Speichern'))
})

// then
const inputField = await screen.findByLabelText(FIELD_LABEL)
expect(inputField.value).toBe(COLOR_2)
expect(apiMock.getMocks().patch).toBeCalledTimes(1)
await waitFor(async () => {
const inputField = await screen.findByLabelText(FIELD_LABEL)
expect(inputField.value).toBe(COLOR_2)
expect(apiMock.getMocks().patch).toBeCalledTimes(1)
})
})

test('updates state if value in store is refreshed and has new value', async () => {
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/components/form/base/ColorPicker/ColorSwatch.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<template>
<v-btn
class="e-colorswatch"
:class="{ 'e-colorswatch--null': color == null }"
fab
elevation="0"
width="30"
height="30"
:color="color"
:ripple="false"
v-bind="$attrs"
@click="$emit('select-color', color)"
v-on="$listeners"
></v-btn>
Expand All @@ -17,7 +19,7 @@ import { contrastColor } from '@/common/helpers/colors.js'
export default {
name: 'ColorSwatch',
props: {
color: { type: String, required: true },
color: { type: String, default: null },
},
computed: {
contrast() {
Expand All @@ -28,6 +30,19 @@ export default {
}
</script>
<style scoped>
.e-colorswatch::before {
background: transparent;
}
.e-colorswatch:focus {
transform: scale(1.1);
}
.e-colorswatch:focus::before {
opacity: 1;
outline: 1px solid v-bind(contrast);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.3),
0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
.e-colorswatch::after {
content: '';
color: v-bind(contrast);
Expand All @@ -38,4 +53,10 @@ export default {
font-size: 28px;
text-align: center;
}
.e-colorswatch--null {
background: #f0f0f0;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
inset 0 0 10px rgba(0, 0, 0, 0.02) !important;
}
</style>
107 changes: 107 additions & 0 deletions frontend/src/components/form/base/EColorField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<template>
<EParseField
ref="input"
:value="value"
:format="format"
:parse="parse"
:serialize="serialize"
:deserialize="deserialize"
:required="required"
:vee-id="veeId"
:vee-rules="veeRules"
reset-on-blur
v-bind="$attrs"
v-on="$listeners"
@input="$emit('input', $event)"
>
<!-- passing through all slots -->
<slot v-for="(_, name) in $slots" :slot="name" :name="name" />
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
<slot v-if="name !== 'prepend'" :name="name" v-bind="slotData" />
</template>
<template #prepend="props">
<slot name="prepend" v-bind="props">
<ColorSwatch
class="mt-n1"
:color="props.serializedValue"
tag="div"
:aria-label="props.serializedValue"
/>
</slot>
</template>
</EParseField>
</template>

<script>
import { reactive } from 'vue'
import { formComponentMixin } from '@/mixins/formComponentMixin.js'
import { parse, serialize } from 'colorjs.io/fn'
import ColorSwatch from '@/components/form/base/ColorPicker/ColorSwatch.vue'
export default {
name: 'EColorField',
components: { ColorSwatch },
mixins: [formComponentMixin],
props: {
value: { type: String, required: false, default: null },
},
emits: ['input'],
methods: {
format(value) {
if (typeof value === 'string') {
return value
}
return !value
? ''
: serialize(value, {
space: 'srgb',
format: 'hex',
collapse: false,
}).toUpperCase()
},
/**
* @param {string} value
*/
parse(value) {
if (value === '') {
return null
}
try {
const color = parse(value)
color.alpha = 1
return reactive(color)
} catch (e) {
if (e instanceof TypeError) {
throw new Error(this.$tc('components.form.base.eColorField.parseError'))
} else {
throw e
}
}
},
/**
* @param {string|null} value
*/
serialize(value) {
try {
return serialize(value, { format: 'hex', collapse: false }).toUpperCase()
} catch (e) {
return null
}
},
/**
* @param value {null|string}
* @return {null|Color}
*/
deserialize(value) {
try {
return !value ? null : reactive(parse(value, { space: 'srgb', format: 'hex' }))
} catch (e) {
return null
}
},
focus() {
this.$refs.input.focus()
},
},
}
</script>
Loading

0 comments on commit ea9e0a8

Please sign in to comment.