Skip to content
This repository has been archived by the owner on May 20, 2024. It is now read-only.

Commit

Permalink
Add agreements (#2593)
Browse files Browse the repository at this point in the history
* Don't error if missing place type

... might just not have loaded yet

* Agreements UI

* Refine agreement display

* Put agreement form in a card

* Small fixes

* Support en-GB locale

* Loading states for agreements and ui tweaks

* Implement cancel button

* Implement DateInput and rename to activeTo

* Make user list items clickable

... I know, random change in this branch, but it was bugging me!

* Add fancy diff view for agreement changes

* History detail tweaks

* Add websocket updates for agreements

* Add create agreement app test

* Please Our Holy Linter

* i18n additions

* Remove duplicate text, more agreement text padding

* No wrap for agreement actions
  • Loading branch information
nicksellen committed Sep 30, 2022
1 parent fbd76f0 commit ecfc7f0
Show file tree
Hide file tree
Showing 36 changed files with 1,421 additions and 48 deletions.
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -55,6 +55,8 @@
"date-fns": "^2.28.0",
"deep-equal": "^2.0.5",
"deepmerge": "^4.2.2",
"diff": "^5.1.0",
"diff2html": "^3.4.19",
"element-closest": "^3.0.0",
"firebase": "^9.9.0",
"floating-vue": "^2.0.0-beta.17",
Expand Down
2 changes: 2 additions & 0 deletions src/App.vue
Expand Up @@ -18,6 +18,7 @@ import { onErrorCaptured } from 'vue'
import { useQueryClient } from 'vue-query'
import { useActivitiesUpdater, useActivitySeriesUpdater, useActivityTypeUpdater } from '@/activities/queries'
import { useAgreementsUpdater } from '@/agreements/queries'
import { useApplicationsUpdater } from '@/applications/queries'
import { useAuthUserUpdater } from '@/authuser/queries'
import { useRoutingLogic, useCheckResponseAuthStatus } from '@/base/services'
Expand Down Expand Up @@ -53,6 +54,7 @@ export default {
useCheckResponseAuthStatus()
// Websocket updaters
useAgreementsUpdater()
useAuthUserUpdater()
useApplicationsUpdater()
useUsersUpdater()
Expand Down
17 changes: 17 additions & 0 deletions src/AppTests/README.md
@@ -0,0 +1,17 @@
# AppTests

This are for doing tests of the WHOLE application (at least from `App` component), including the routing, layout, etc...

A few points:
- you can only have ONE test per file
- in theory there could be more, but it's *really* hard to reset everything between them, I failed with wierd vue errors, maybe one day it JustWorks™
- we only mock the backend for these, see `test/mockBackend` for more
- they are slower, but very comprehensive tests

## Tips

If the output diff is not showing you enough you can use:

```shell
DEBUG_PRINT_LIMIT=100000 yarn test src/AppTests/yourAppTest.spec.js
```
79 changes: 79 additions & 0 deletions src/AppTests/createAgreement.spec.js
@@ -0,0 +1,79 @@
import '@testing-library/jest-dom'
import { faker } from '@faker-js/faker'
import userEvent from '@testing-library/user-event'
import { render, configure } from '@testing-library/vue'

import App from '@/App'
import router from '@/router'

import { withDefaults } from '>/helpers'
import { useMockBackend, createUser, createGroup, loginAs, db } from '>/mockBackend'
import { addUserToGroup } from '>/mockBackend/groups'

useMockBackend()
jest.setTimeout(60 * 1000) // we do a lot of stuff here, give it some time!

configure({
asyncUtilTimeout: 2000,
})

test('create an agreement', async () => {
const { type, click } = userEvent.setup()

const user = createUser()
const group = createGroup({ features: ['agreements'] })
addUserToGroup(user, group)

const otherUser = createUser()
addUserToGroup(otherUser, group)

user.currentGroup = group.id
loginAs(user)

const {
getByText,
findByText,
findAllByText,
findByRole,
findByTitle,
} = render(App, withDefaults({
global: { plugins: [router], stubs: { RouterLink: false } },
}))

// go to agreements page and click to make a new one
await click(await findByRole('link', { name: 'Agreements' }))
await click(await findByTitle('Create'))

const title = faker.lorem.words(5)
const summary = faker.lorem.paragraph()
const content = faker.lorem.paragraphs(10)

// fill in the form
await type(await findByRole('textbox', { name: 'Agreement Title' }), title)
await type(await findByRole('textbox', { name: 'Summary (optional)' }), summary)
await type(await findByRole('textbox', { name: 'Agreement Text' }), content)

await click(await findByRole('button', { name: 'Create' }))

// see it on the page!
// (using the All variant as it appears twice... (breadcrumbs, and body)
await findAllByText(title)
await findByText(summary)
// this gets turned into markdown, so swap newlines for .* regexp...
const re = new RegExp(content.split('\n').join('.*'))
await findByText(re)

expect(router.currentRoute.value.name).toEqual('agreement')

// make sure the data ended up in the db
const agreement = db.agreements[0]
expect(agreement.title).toEqual(title)
expect(agreement.summary).toEqual(summary)
expect(agreement.content).toEqual(content)

// shows it's active
expect(getByText('Active')).toBeInTheDocument()

// has history view
expect(getByText('agreement created')).toBeInTheDocument()
})
49 changes: 49 additions & 0 deletions src/agreements/api/agreements.js
@@ -0,0 +1,49 @@
import axios, { parseCursor } from '@/base/api/axios'
import { underscorizeKeys } from '@/utils/utils'

export default {

async create (agreement) {
return convert((await axios.post('/api/agreements/', agreement)).data)
},

async get (agreementId) {
return convert((await axios.get(`/api/agreements/${agreementId}/`)).data)
},

async list (params = {}) {
const response = (await axios.get('/api/agreements/', { params: underscorizeKeys(params) })).data
return {
...response,
next: parseCursor(response.next),
results: convert(response.results),
}
},

async save (agreement) {
return convert((await axios.patch(`/api/agreements/${agreement.id}/`, agreement)).data)
},
}

export function convert (val) {
if (Array.isArray(val)) {
return val.map(convert)
}
else {
const result = { ...val }

const dateFields = [
'activeFrom',
'activeTo',
'reviewAt',
]

for (const field of dateFields) {
if (val[field]) {
result[field] = new Date(val[field])
}
}

return result
}
}
220 changes: 220 additions & 0 deletions src/agreements/components/AgreementForm.vue
@@ -0,0 +1,220 @@
<template>
<QCard
class="edit-box"
:class="{ changed: hasChanged }"
>
<form
class="q-gutter-y-lg"
style="max-width: 700px"
@submit.prevent="maybeSave"
>
<h3>
<QIcon
name="fas fa-handshake"
class="q-pr-md"
color="grey"
/>
<template v-if="isNew">
{{ $t('AGREEMENT.NEW') }}
</template>
<template v-else>
{{ $t('AGREEMENT.EDIT') }}
</template>
</h3>

<QInput
v-model="edit.title"
:autofocus="!$q.platform.has.touch"
v-bind="titleError"
:label="$t('AGREEMENT.TITLE')"
:hint="$t('AGREEMENT.TITLE_HELPER')"
outlined
class="q-mb-lg"
@blur="v$.edit.title.$touch"
/>

<div class="row">
<DateInput
v-model="edit.activeFrom"
:error="hasError('activeFrom')"
:label="$t('AGREEMENT.ACTIVE_FROM')"
/>
<DateInput
v-model="edit.activeTo"
:error="hasError('activeTo')"
:label="$t('AGREEMENT.ACTIVE_TO')"
clearable
/>
</div>

<DateInput
v-model="edit.reviewAt"
:error="hasError('reviewAt')"
:label="`${$t('AGREEMENT.REVIEW_AT')} (${$t('VALIDATION.OPTIONAL')})`"
clearable
/>

<MarkdownInput
v-model="edit.summary"
:error="hasError('summary')"
:error-message="firstError('summary')"
:label="`${$t('AGREEMENT.SUMMARY')} (${$t('VALIDATION.OPTIONAL')})`"
outlined
class="q-mb-lg"
@keyup.ctrl.enter="maybeSave"
/>

<MarkdownInput
v-model="edit.content"
:error="hasError('content')"
:error-message="firstError('content')"
:label="$t('AGREEMENT.CONTENT')"
outlined
class="q-mb-lg"
@keyup.ctrl.enter="maybeSave"
/>

<div class="row justify-end q-gutter-sm q-mt-sm">
<QBtn
type="button"
@click="$emit('cancel')"
>
{{ $t('BUTTON.CANCEL') }}
</QBtn>

<QBtn
type="submit"
color="primary"
:disable="!canSave"
:loading="isPending"
>
{{ $t(isNew ? 'BUTTON.CREATE' : 'BUTTON.SAVE_CHANGES') }}
</QBtn>
</div>
</form>
</QCard>
</template>

<script>
import useVuelidate from '@vuelidate/core'
import { maxLength, required } from '@vuelidate/validators'
import {
QCard,
QIcon,
QInput,
QBtn,
date,
} from 'quasar'
import editMixin from '@/utils/mixins/editMixin'
import statusMixin, { mapErrors } from '@/utils/mixins/statusMixin'
import DateInput from '@/utils/components/DateInput'
import MarkdownInput from '@/utils/components/MarkdownInput'
export default {
components: {
DateInput,
QCard,
QIcon,
QInput,
QBtn,
MarkdownInput,
},
mixins: [editMixin, statusMixin],
props: {
value: {
type: Object,
default: () => ({
title: undefined,
summary: undefined,
content: undefined,
activeFrom: new Date(),
reviewAt: undefined,
}),
},
},
emits: [
'save',
'cancel',
],
setup () {
return {
v$: useVuelidate(),
}
},
computed: {
...mapErrors({
title: [
['required', 'VALIDATION.REQUIRED'],
['maxLength', 'VALIDATION.MAXLENGTH', { max: 241 }],
],
summary: [],
content: [
['required', 'VALIDATION.REQUIRED'],
],
}),
canSave () {
if (!this.isNew && !this.hasChanged) {
return false
}
return true
},
smallScreen () {
return this.$q.screen.width < 450 || this.$q.screen.height < 450
},
activeFrom: {
get () {
return date.formatDate(this.edit.activeFrom, 'YYYY-MM-DD')
},
set (val) {
if (val) {
val = date.extractDate(val, 'YYYY-MM-DD')
val = date.adjustDate(this.edit.activeFrom, { year: val.getFullYear(), month: val.getMonth() + 1, date: val.getDate() })
}
this.edit.activeFrom = val
},
},
reviewAt: {
get () {
return date.formatDate(this.edit.reviewAt, 'YYYY-MM-DD')
},
set (val) {
if (val) {
val = date.extractDate(val, 'YYYY-MM-DD')
val = date.adjustDate(this.edit.reviewAt, { year: val.getFullYear(), month: val.getMonth() + 1, date: val.getDate() })
}
this.edit.reviewAt = val || null
},
},
},
methods: {
maybeSave () {
if (!this.canSave) return
this.save()
},
focusReviewAt (evt) {
// If it's a button, it's the "clear" button
// so we don't want to pop open the calendar
if (evt.target.nodeName !== 'BUTTON') {
this.$refs.qReviewAtProxy.show()
}
},
},
validations: {
edit: {
title: {
required,
maxLength: maxLength(240),
},
content: {
required,
},
},
},
}
</script>

<style scoped lang="sass">
@import '~editbox'
</style>

0 comments on commit ecfc7f0

Please sign in to comment.