Merge pull request #6381 from Ocelot-Social-Community/merge-6336-into-6339-optimize-event-create-and-update

refactor(webapp): optimize create and update event form
This commit is contained in:
Hannes Heine 2023-06-08 20:14:00 +02:00 committed by GitHub
commit 3cfc5e30a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 319 additions and 89 deletions

View File

@ -1,29 +1,32 @@
import { UserInputError } from 'apollo-server'
export const validateEventParams = (params) => {
let locationName = null
if (params.postType && params.postType === 'Event') {
const { eventInput } = params
validateEventDate(eventInput.eventStart)
params.eventStart = eventInput.eventStart
if (eventInput.eventEnd) {
validateEventEnd(eventInput.eventStart, eventInput.eventEnd)
params.eventEnd = eventInput.eventEnd
} else {
params.eventEnd = null
}
if (eventInput.eventLocationName && !eventInput.eventVenue) {
throw new UserInputError('Event venue must be present if event location is given!')
}
params.eventVenue = eventInput.eventVenue
params.eventLocationName = eventInput.eventLocationName
params.eventLocationName = eventInput.eventLocationName && eventInput.eventLocationName.trim()
if (params.eventLocationName) {
locationName = params.eventLocationName
} else {
params.eventLocationName = null
}
params.eventIsOnline = !!eventInput.eventIsOnline
}
delete params.eventInput
let locationName
if (params.eventLocationName) {
locationName = params.eventLocationName
} else {
params.eventLocationName = null
locationName = null
}
return locationName
}

View File

@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue'
import PostMutations from '~/graphql/PostMutations.js'
import Vuex from 'vuex'
import PostMutations from '~/graphql/PostMutations.js'
import ImageUploader from '~/components/Uploader/ImageUploader'
import MutationObserver from 'mutation-observer'
@ -108,6 +108,10 @@ describe('ContributionForm.vue', () => {
await wrapper.vm.updateEditorContent(postContent)
})
it('has no event data block', () => {
expect(wrapper.find('div.eventDatas').exists()).toBe(false)
})
it('title cannot be empty', async () => {
postTitleInput.setValue('')
wrapper.find('form').trigger('submit')
@ -293,5 +297,88 @@ describe('ContributionForm.vue', () => {
})
})
})
describe('Events', () => {
beforeEach(() => {
propsData.createEvent = true
wrapper = Wrapper()
})
it('has event data block', () => {
expect(wrapper.find('div.eventDatas').exists()).toBe(true)
})
describe('is online event', () => {
it('has false as default', () => {
expect(wrapper.vm.formData.eventIsOnline).toBe(false)
})
it('has input for event location', () => {
expect(wrapper.find('input[name="eventLocationName"]').exists()).toBe(true)
})
describe('click is online event', () => {
beforeEach(() => {
wrapper.find('input[name="eventIsOnline"]').setChecked(true)
})
it('has no input for event location', () => {
expect(wrapper.find('input[name="eventLocationName"]').exists()).toBe(false)
})
})
describe('invalid form', () => {
beforeEach(() => {
wrapper.find('input[name="title"]').setValue('Illegaler Kindergeburtstag')
wrapper.vm.updateEditorContent('Elli hat Geburtstag!')
})
it('has submit button disabled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
})
describe('valid form', () => {
const now = new Date()
beforeEach(() => {
wrapper.find('input[name="title"]').setValue('Illegaler Kindergeburtstag')
wrapper.vm.updateEditorContent('Elli hat Geburtstag!')
wrapper
.findComponent({ name: 'DatePicker' })
.vm.$emit('change', new Date(now.getFullYear(), now.getMonth() + 1).toISOString())
wrapper.find('input[name="eventVenue"]').setValue('Ellis Kinderzimmer')
wrapper.find('input[name="eventLocationName"]').setValue('Deutschland')
})
it('has submit button not disabled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe(undefined)
})
describe('submit', () => {
beforeEach(() => {
wrapper.find('form').trigger('submit')
})
it('calls create post', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: PostMutations().CreatePost,
variables: expect.objectContaining({
title: 'Illegaler Kindergeburtstag',
content: 'Elli hat Geburtstag!',
eventInput: {
eventStart: new Date(now.getFullYear(), now.getMonth() + 1).toISOString(),
eventVenue: 'Ellis Kinderzimmer',
eventLocationName: 'Deutschland',
eventIsOnline: false,
eventEnd: null,
},
}),
})
})
})
})
})
})
})
})

View File

@ -54,13 +54,13 @@
</ds-chip>
<!-- Eventdata -->
<div v-if="creatEvent" class="eventDatas">
<div v-if="createEvent" class="eventDatas">
<hr />
<ds-space margin-top="x-small" />
<ds-grid>
<ds-grid-item style="grid-row-end: span 3">
<ds-grid-item class="event-grid-item">
<!-- <label>Beginn</label> -->
<div style="z-index: 20">
<div class="event-grid-item-z-helper">
<date-picker
name="eventStart"
v-model="formData.eventStart"
@ -68,42 +68,45 @@
value-type="format"
:minute-step="15"
Xformat="DD-MM-YYYY HH:mm"
style="z-index: 20"
class="event-grid-item-z-helper"
:placeholder="$t('post.viewEvent.eventStart')"
:disabled-date="notBeforeToday"
:disabled-time="notBeforeNow"
:show-second="false"
@change="changeEventStart($event)"
></date-picker>
</div>
<div class="chipbox" style="margin-top: 10px">
<div v-if="errors && errors.eventStart" class="chipbox event-grid-item-margin-helper">
<ds-chip size="base" :color="errors && errors.eventStart && 'danger'">
<base-icon v-if="errors && errors.eventStart" name="warning" />
<base-icon name="warning" />
</ds-chip>
</div>
</ds-grid-item>
<ds-grid-item style="grid-row-end: span 3">
<ds-grid-item class="event-grid-item">
<!-- <label>Ende (optional)</label> -->
<date-picker
v-model="formData.eventEnd"
name="eventEnd"
type="datetime"
value-type="format"
:minute-step="15"
:seconds-step="0"
Xformat="DD-MM-YYYY HH:mm"
:placeholder="$t('post.viewEvent.eventEnd')"
style="font-size: larger"
class="event-grid-item-font-helper"
:disabled-date="notBeforeEventDay"
:disabled-time="notBeforeEvent"
:show-second="false"
@change="changeEventEnd($event)"
></date-picker>
</ds-grid-item>
</ds-grid>
<ds-grid>
<ds-grid-item style="grid-row-end: span 3">
<ds-grid class="event-location-grid">
<ds-grid-item class="event-grid-item">
<ds-input
model="eventVenue"
name="location"
name="eventVenue"
:placeholder="$t('post.viewEvent.eventVenue')"
/>
<div class="chipbox">
@ -113,10 +116,10 @@
</ds-chip>
</div>
</ds-grid-item>
<ds-grid-item style="grid-row-end: span 3">
<ds-grid-item v-if="showEventLocationName" class="event-grid-item">
<ds-input
model="eventLocationName"
name="venue"
name="eventLocationName"
:placeholder="$t('post.viewEvent.eventLocationName')"
/>
<div class="chipbox">
@ -131,9 +134,11 @@
<div>
<input
type="checkbox"
model="formData.eventIsOnline"
v-model="formData.eventIsOnline"
model="eventIsOnline"
name="eventIsOnline"
style="font-size: larger"
class="event-grid-item-font-helper"
@change="changeEventIsOnline($event)"
/>
{{ $t('post.viewEvent.eventIsOnline') }}
</div>
@ -153,7 +158,7 @@
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<ds-flex class="buttons-footer" gutter="xxx-small">
<ds-flex-item width="3.5" style="margin-right: 16px; margin-bottom: 6px">
<ds-flex-item width="3.5" class="buttons-footer-helper">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<!-- TODO => remove v-html! only text ! no html! security first! -->
<ds-text
@ -210,7 +215,7 @@ export default {
type: Object,
default: () => null,
},
creatEvent: {
createEvent: {
type: Boolean,
default: false,
},
@ -252,24 +257,6 @@ export default {
eventVenue: eventVenue || '',
eventIsOnline: eventIsOnline || false,
},
formSchema: {
title: { required: true, min: 3, max: 100 },
content: { required: true },
imageBlurred: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (this.categoriesActive && (value.length === 0 || value.length > 3)) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
eventStart: { required: !!this.creatEvent },
eventVenue: { required: !!this.creatEvent, min: 3, max: 100 },
eventLocationName: { required: !!this.creatEvent, min: 3, max: 100 },
},
loading: false,
users: [],
hashtags: [],
@ -283,14 +270,69 @@ export default {
...mapGetters({
currentUser: 'auth/user',
}),
formSchema() {
return {
title: { required: true, min: 3, max: 100 },
content: { required: true },
imageBlurred: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (this.categoriesActive && (value.length === 0 || value.length > 3)) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
eventStart: { required: !!this.createEvent },
eventVenue: {
required: !!this.createEvent,
min: 3,
max: 100,
validator: (_, value = '') => {
if (!this.createEvent) return []
if (!value.trim()) {
return [new Error(this.$t('common.validations.eventVenueNotEmpty'))]
}
if (value.length < 3 || value.length > 100) {
return [
new Error(this.$t('common.validations.eventVenueLength', { min: 3, max: 100 })),
]
}
return []
},
},
eventLocationName: {
required: !!this.createEvent && !this.formData.eventIsOnline,
min: 3,
max: 100,
validator: (_, value = '') => {
if (!this.createEvent) return []
if (this.formData.eventIsOnline) return []
if (!value.trim()) {
return [new Error(this.$t('common.validations.eventLocationNameNotEmpty'))]
}
if (value.length < 3 || value.length > 100) {
return [
new Error(
this.$t('common.validations.eventLocationNameLength', { min: 3, max: 100 }),
),
]
}
return []
},
},
}
},
eventInput() {
if (this.creatEvent) {
if (this.createEvent) {
return {
eventStart: this.formData.eventStart,
eventVenue: this.formData.eventVenue,
eventEnd: this.formData.eventEnd,
eventIsOnline: this.formData.eventIsOnline,
eventLocationName: this.formData.eventLocationName,
eventLocationName: !this.formData.eventIsOnline ? this.formData.eventLocationName : null,
}
}
return undefined
@ -310,6 +352,9 @@ export default {
groupCategories() {
return this.group && this.group.categories
},
showEventLocationName() {
return !this.formData.eventIsOnline
},
},
watch: {
groupCategories() {
@ -356,7 +401,7 @@ export default {
id: this.contribution.id || null,
image,
groupId: this.groupId,
postType: !this.creatEvent ? 'Article' : 'Event',
postType: !this.createEvent ? 'Article' : 'Event',
eventInput: this.eventInput,
},
})
@ -378,6 +423,15 @@ export default {
updateEditorContent(value) {
this.$refs.contributionForm.update('content', value)
},
changeEventIsOnline(event) {
this.$refs.contributionForm.update('eventIsOnline', this.formData.eventIsOnline)
},
changeEventEnd(event) {
this.$refs.contributionForm.update('eventEnd', event)
},
changeEventStart(event) {
this.$refs.contributionForm.update('eventStart', event)
},
addHeroImage(file) {
this.formData.image = null
if (file) {
@ -443,6 +497,24 @@ export default {
margin-top: -10px;
}
}
// style override to handle dynamic inputs
.event-location-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.event-grid-item {
// important needed because of component inline style
grid-row-end: span 3 !important;
}
.event-grid-item-z-helper {
z-index: 20;
}
.event-grid-item-margin-helper {
margin-top: 10px;
}
.event-grid-item-font-helper {
font-size: larger;
}
}
.contribution-form > .base-card {
@ -491,6 +563,12 @@ export default {
min-width: fit-content;
}
}
> .buttons-footer-helper {
margin-right: 16px;
// important needed because of component inline style
margin-bottom: 6px !important;
}
}
.blur-toggle {

View File

@ -61,6 +61,8 @@ export default () => {
$content: String!
$image: ImageInput
$categoryIds: [ID]
$postType: PostType
$eventInput: _EventInput
) {
UpdatePost(
id: $id
@ -68,6 +70,8 @@ export default () => {
content: $content
image: $image
categoryIds: $categoryIds
postType: $postType
eventInput: $eventInput
) {
id
title
@ -85,6 +89,14 @@ export default () => {
name
role
}
postType
eventStart
eventLocationName
eventVenue
eventLocation {
lng
lat
}
}
}
`,

View File

@ -115,6 +115,10 @@
"validations": {
"categories": "es müssen eine bis drei Themen ausgewählt werden",
"email": "muss eine gültige E-Mail-Adresse sein",
"eventLocationNameLength": "Minimum {min}, Maximum {max} Zeichen",
"eventLocationNameNotEmpty": "es dürfen nicht nur Freizeichen eingetragen werden",
"eventVenueLength": "Minimum {min}, Maximum {max} Zeichen",
"eventVenueNotEmpty": "es dürfen nicht nur Freizeichen eingetragen werden",
"url": "muss eine gültige URL sein"
},
"versus": "Versus"
@ -726,6 +730,7 @@
},
"edited": "bearbeitet",
"editPost": {
"event": "Bearbeite deine Veranstaltung",
"forGroup": {
"title": "Für die Gruppe „{name}“"
},

View File

@ -115,6 +115,10 @@
"validations": {
"categories": "at least one and at most three topics must be selected",
"email": "must be a valid e-mail address",
"eventLocationNameLength": "minimum {min} or maximum {max} characters",
"eventLocationNameNotEmpty": "only empty characters are not allowed",
"eventVenueLength": "minimum {min} or maximum {max} characters",
"eventVenueNotEmpty": "only empty characters are not allowed",
"url": "must be a valid URL"
},
"versus": "Versus"
@ -722,10 +726,11 @@
"forGroup": {
"title": "For The Group “{name}”"
},
"title": "Create A New Post"
"title": "Create A New Article"
},
"edited": "edited",
"editPost": {
"event": "Edit Your Event",
"forGroup": {
"title": "For The Group “{name}”"
},

View File

@ -64,22 +64,41 @@
<base-icon name="map-marker" data-test="map-marker" />
<span v-if="post.eventVenue">{{ post.eventVenue }}</span>
<span v-if="!post.eventIsOnline">
<span v-if="post.eventVenue">-</span>
<span v-if="post.eventVenue">&mdash;</span>
{{ post.eventLocationName }}
</span>
<span v-else>
<span v-if="post.eventVenue">-</span>
<span v-if="post.eventVenue">&mdash;</span>
{{ $t('post.viewEvent.eventIsOnline') }}
</span>
</ds-text>
<ds-text align="left" color="soft" class="event-info">
<base-icon name="calendar" data-test="calendar" />
<span>{{ getEventDateString }}</span>
<div>
<div>
<base-icon name="calendar" data-test="calendar" />
<span>{{ getEventStartDateString }}</span>
</div>
<div>
<base-icon name="clock" data-test="calendar" />
<span>{{ getEventStartTimeString }}</span>
</div>
</div>
<div v-if="getEventEndDateString">&nbsp;&mdash;&nbsp;</div>
<div v-if="getEventEndDateString">
<div>
<base-icon name="calendar" data-test="calendar" />
<span>{{ getEventEndDateString }}</span>
</div>
<div>
<base-icon name="clock" data-test="calendar" />
<span>{{ getEventEndTimeString }}</span>
</div>
</div>
</ds-text>
<ds-text v-if="getEventTimeString" align="left" color="soft" class="event-info">
<!--ds-text v-if="getEventTimeString" align="left" color="soft" class="event-info">
<base-icon name="clock" data-test="calendar" />
<span>{{ getEventTimeString }}</span>
</ds-text>
</ds-text-->
</ds-space>
<ds-space margin-bottom="small" />
<content-viewer class="content hyphenate-text" :content="post.content" />
@ -225,7 +244,10 @@ export default {
const { slug, id } = this.$route.params
return [
{
name: this.$t('common.post', null, 1),
name:
this.post?.postType[0] === 'Event'
? this.$t('post.viewEvent.title')
: this.$t('post.viewPost.title'),
path: `/post/${id}/${slug}`,
children: [
{
@ -288,26 +310,17 @@ export default {
!this.post.group || (this.group && ['usual', 'admin', 'owner'].includes(this.group.myRole))
)
},
getEventDateString() {
if (this.post.eventEnd) {
const eventStart = format(new Date(this.post.eventStart), 'dd.MM.')
const eventEnd = format(new Date(this.post.eventEnd), 'dd.MM.yyyy')
return `${eventStart} - ${eventEnd}`
} else {
return format(new Date(this.post.eventStart), 'dd.MM.yyyy')
}
getEventStartDateString() {
return format(new Date(this.post.eventStart), 'dd.MM.yyyy')
},
getEventTimeString() {
if (this.post.eventEnd) {
const eventStartTime = format(new Date(this.post.eventStart), 'HH:mm')
const eventEndTime = format(new Date(this.post.eventEnd), 'HH:mm')
/* assumption that if e.g. 00:00 == 00:00 is saved,
it's not realistic because they are the default values, so don't show the time info.
*/
return eventStartTime !== eventEndTime ? `${eventStartTime} - ${eventEndTime}` : ''
} else {
return format(new Date(this.post.eventStart), 'HH:mm')
}
getEventStartTimeString() {
return format(new Date(this.post.eventStart), 'HH:mm')
},
getEventEndDateString() {
return this.post.eventEnd ? format(new Date(this.post.eventEnd), 'dd.MM.yyyy') : ''
},
getEventEndTimeString() {
return this.post.eventEnd ? format(new Date(this.post.eventEnd), 'HH:mm') : ''
},
},
methods: {

View File

@ -1,13 +1,13 @@
<template>
<div>
<ds-flex :width="{ base: '100%' }">
<ds-flex-item :width="{ base: '100%', md: 5 }">
<ds-flex-item :width="{ base: '100%' }">
<ds-flex gutter="base" :width="{ base: '100%', sm: 1 }">
<ds-flex-item>
<ds-card :primary="!creatEvent" centered>
<ds-card class="create-form-btn" :primary="!createEvent" centered>
<div>
<ds-button
v-if="!creatEvent"
v-if="!createEvent"
ghost
fullwidth
size="x-large"
@ -15,26 +15,26 @@
>
{{ $t('post.createNewPost.title') }}
</ds-button>
<ds-button v-else ghost fullwidth size="x-large" @click="creatEvent = !creatEvent">
<ds-button v-else ghost fullwidth size="x-large" @click="switchPostType()">
{{ $t('post.createNewPost.title') }}
</ds-button>
</div>
</ds-card>
</ds-flex-item>
<ds-flex-item>
<ds-card :primary="!!creatEvent" centered>
<ds-card class="create-form-btn" :primary="!!createEvent" centered>
<div>
<ds-button
ghost
fullwidth
size="x-large"
v-if="creatEvent"
v-if="createEvent"
hover
class="inactive-tab-button"
>
{{ $t('post.createNewEvent.title') }}
</ds-button>
<ds-button ghost fullwidth size="x-large" v-else @click="creatEvent = !creatEvent">
<ds-button ghost fullwidth size="x-large" v-else @click="switchPostType()">
{{ $t('post.createNewEvent.title') }}
</ds-button>
</div>
@ -45,14 +45,12 @@
{{ $t('post.createNewPost.forGroup.title', { name: group.name }) }}
</div>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex>
<ds-flex :width="{ base: '100%' }" gutter="base">
<ds-flex-item :width="{ base: '100%', md: 5 }">
<contribution-form :group="group" :creatEvent="creatEvent" />
<ds-flex-item :width="{ base: '100%' }">
<contribution-form :group="group" :createEvent="createEvent" />
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
</ds-flex>
</div>
</template>
@ -69,7 +67,7 @@ export default {
const { groupId = null } = this.$route.query
return {
groupId,
creatEvent: false,
createEvent: false,
}
},
computed: {
@ -98,6 +96,11 @@ export default {
fetchPolicy: 'cache-and-network',
},
},
methods: {
switchPostType() {
this.createEvent = !this.createEvent
},
},
}
</script>
<style lang="scss">
@ -109,4 +112,12 @@ export default {
font-size: 30px;
text-align: center;
}
// copy hover effect from ghost button to use for ds-card
.create-form-btn:not(.ds-card-primary):hover {
background-color: #faf9fa;
}
.create-form-btn .ds-button-ghost:hover {
background-color: transparent;
}
</style>

View File

@ -29,7 +29,12 @@ describe('post/_id.vue', () => {
defaultClient: {
query: jest.fn().mockResolvedValue({
data: {
Post: [{ author: { id: authorId } }],
Post: [
{
author: { id: authorId },
postType: ['Article'],
},
],
},
}),
},

View File

@ -1,7 +1,13 @@
<template>
<div>
<ds-space margin="small">
<ds-heading tag="h1">{{ $t('post.editPost.title') }}</ds-heading>
<ds-heading tag="h1">
{{
contribution && contribution.postType[0] === 'Event'
? $t('post.editPost.event')
: $t('post.editPost.title')
}}
</ds-heading>
<ds-heading v-if="contribution && contribution.group" tag="h2">
{{ $t('post.editPost.forGroup.title', { name: contribution.group.name }) }}
</ds-heading>
@ -12,6 +18,7 @@
<contribution-form
:contribution="contribution"
:group="contribution && contribution.group ? contribution.group : null"
:createEvent="contribution && contribution.postType[0] === 'Event'"
/>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
@ -34,7 +41,11 @@ export default {
}),
},
data() {
return { contribution: {} }
return {
contribution: {
postType: ['Article'],
},
}
},
async asyncData(context) {
const {