Streamline UI for creating activities #2585
Changes from 86 commits
bed7357
7784b11
0e59fa4
c53c8a3
287a671
34f975b
b26ac96
bed2f12
a5ad72d
9f3b631
1008250
ed50461
f0c3e6e
bf027e5
369e5c7
c7c3bc8
679ac94
8fdd3c6
4574a2f
03cf5e9
e86754b
445bd4b
5cfc8c5
fc7e32e
be124df
5740272
89dbf39
261dc72
2031553
f5a426c
ba1764d
8d7ec09
24d8c89
972718c
b0a3517
a926e7a
5594a24
ae78023
1215755
5a149c4
e35eecc
4206c02
a6f3aec
a39e6d1
5db28d4
80b2963
404b2ec
fc039fa
566cc32
4da270d
5b08bcf
2d643e5
ba39b69
f41bf16
4252ab9
7f926cf
2b78186
ebc7aff
9fc48a6
54f31b1
39b4cdb
3724a10
af88ae2
35279a7
2ecefb9
ee7c7a1
cf626e5
36221f6
4e24fa2
b046684
46d9c5e
80894e8
8f0521f
b1773cd
d73ee4e
f2ec177
a700aa8
25b7451
505b5aa
75c7769
f1e4df0
56d25a6
10b7438
28810b0
ffc8982
a3b07be
f007aac
7ded91e
7368213
0e57cae
c8de284
4d17724
c826719
7713e71
60abdcc
f128636
b81a011
76fa7bb
7e242db
f30b398
16ac4cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import '@testing-library/jest-dom' | ||
import userEvent from '@testing-library/user-event' | ||
import { render } from '@testing-library/vue' | ||
import { times } from 'lodash' | ||
|
||
import { resetServices } from '@/utils/datastore/helpers' | ||
|
||
import { withDefaults } from '>/helpers' | ||
import { | ||
db, | ||
useMockBackend, | ||
createUser, | ||
createGroup, | ||
loginAs, | ||
setPageSize, | ||
createPlace, | ||
createPlaceType, | ||
createActivityType, | ||
} from '>/mockBackend' | ||
import { addUserToGroup } from '>/mockBackend/groups' | ||
import '>/routerMocks' | ||
|
||
import ActivityCreateButton from './ActivityCreateButton' | ||
|
||
describe('ActivityCreateButton', () => { | ||
let activityType | ||
let places | ||
useMockBackend() | ||
|
||
beforeEach(() => { | ||
jest.resetModules() | ||
resetServices() | ||
}) | ||
|
||
beforeEach(() => { | ||
const user = createUser() | ||
const group = createGroup() | ||
addUserToGroup(user, group) | ||
user.currentGroup = group.id | ||
setPageSize(3) // make it have to do pagination stuff too... | ||
createPlaceType({ group: group.id }) | ||
places = times(3, () => createPlace({ group: group.id })) | ||
activityType = createActivityType({ group: group.id }) | ||
loginAs(user) | ||
}) | ||
|
||
it('creates a new activity', async () => { | ||
const { click } = userEvent.setup() | ||
|
||
const { findByRole, getByRole, getAllByRole } = render(ActivityCreateButton, withDefaults()) | ||
|
||
await click(getByRole('button', { title: /create/i })) | ||
await click(getAllByRole('button', { name: activityType.name })[0]) | ||
|
||
await click(getByRole('combobox', { name: 'Choose a place' })) | ||
await click(await findByRole('option', { name: places[0].name })) | ||
await click(await findByRole('option', { name: places[0].name })) | ||
|
||
expect(db.activities.length).toEqual(0) | ||
await click(getByRole('button', { name: /create/i })) | ||
|
||
expect(db.activities.length).toEqual(1) | ||
expect(db.activities[0].place).toEqual(places[0].id) | ||
expect(db.activities[0].activityType).toEqual(activityType.id) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,238 @@ | ||
<template> | ||
<QFab | ||
class="fab-top-fix" | ||
vertical-actions-align="right" | ||
size="sm" | ||
color="secondary" | ||
icon="fas fa-plus" | ||
direction="down" | ||
unelevated | ||
padding="0px 13px" | ||
:title="$t('BUTTON.CREATE')" | ||
> | ||
<QFabAction | ||
v-for="activityType in activityTypes" | ||
:key="activityType.id" | ||
class="fab-action-fix" | ||
label-position="left" | ||
v-bind="getIconProps(activityType)" | ||
@click="selectActivityTypeAndOpen(activityType)" | ||
/> | ||
<QFabAction | ||
class="fab-action-fix bg-white" | ||
:label="$t('ACTIVITY_TYPES.MANAGE_TYPES')" | ||
outline | ||
:to="{ name: 'groupEditActivityTypes' }" | ||
/> | ||
</QFab> | ||
<QDialog | ||
v-model="isOpen" | ||
> | ||
<div | ||
class="bg-white" | ||
style="width: 700px; overflow-x: hidden" | ||
> | ||
<Component | ||
:is="isSeries ? ActivitySeriesEdit : ActivityEdit" | ||
:value="isSeries ? newSeries : newActivity" | ||
:status="isSeries ? createSeriesStatus : createActivityStatus" | ||
can-cancel | ||
@save="data => isSeries ? saveNewSeries(data) : saveNewActivity(data)" | ||
@cancel="isOpen = false" | ||
@reset="() => isSeries ? resetNewSeries() : resetNewSeries()" | ||
> | ||
<QSelect | ||
v-model="placeId" | ||
:options="places.map(({ name, id, placeType }) => ({ label: name, value: id, icon: getPlaceTypeById(placeType).icon }))" | ||
:label="$t('CREATEACTIVITY.PLACE')" | ||
emit-value | ||
map-options | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... and maybe ordered There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... or even better, if we can make a your one has the added "place type icon" feature which would also be nice for the activity filter. |
||
> | ||
<template #option="scope"> | ||
<QItem | ||
:key="scope.index" | ||
dense | ||
v-bind="scope.itemProps" | ||
> | ||
<QItemSection side> | ||
<QIcon | ||
:name="scope.opt.icon" | ||
size="1.1em" | ||
color="positive" | ||
/> | ||
</QItemSection> | ||
<QItemSection> | ||
<QItemLabel>{{ scope.opt.label }}</QItemLabel> | ||
</QItemSection> | ||
</QItem> | ||
</template> | ||
<template #selected-item="scope"> | ||
<div class="row no-wrap ellipsis"> | ||
<QIcon | ||
:name="scope.opt.icon" | ||
size="1.1em" | ||
class="on-left q-ml-xs" | ||
color="positive" | ||
/> | ||
<div class="ellipsis"> | ||
{{ scope.opt.label }} | ||
</div> | ||
</div> | ||
</template> | ||
</QSelect> | ||
<QOptionGroup | ||
v-model="isSeries" | ||
:options="[ | ||
{ label: $t('CREATEACTIVITY.ONE_TIME_ACTIVITY'), value: false }, | ||
{ label: $t('ACTIVITYMANAGE.SERIES'), value: true }, | ||
]" | ||
color="primary" | ||
inline | ||
class="q-mb-md" | ||
/> | ||
</Component> | ||
</div> | ||
</QDialog> | ||
</template> | ||
|
||
<script setup> | ||
import addHours from 'date-fns/addHours' | ||
import addSeconds from 'date-fns/addSeconds' | ||
import startOfTomorrow from 'date-fns/startOfTomorrow' | ||
import { | ||
QItem, | ||
QItemSection, | ||
QItemLabel, | ||
QIcon, | ||
QFab, | ||
QFabAction, | ||
QDialog, | ||
QSelect, | ||
QOptionGroup, | ||
} from 'quasar' | ||
import { ref, computed, watch, unref, defineAsyncComponent } from 'vue' | ||
|
||
import { useActivityTypeHelpers } from '@/activities/helpers' | ||
import { useCreateActivityMutation, useCreateActivitySeriesMutation } from '@/activities/mutations' | ||
import { useActivityTypeService } from '@/activities/services' | ||
import { defaultDuration } from '@/activities/settings' | ||
import { useCurrentGroupService } from '@/group/services' | ||
import { usePlaceService, usePlaceTypeService } from '@/places/services' | ||
|
||
const ActivityEdit = defineAsyncComponent(() => import('./ActivityEdit.vue')) | ||
const ActivitySeriesEdit = defineAsyncComponent(() => import('./ActivitySeriesEdit.vue')) | ||
|
||
const isOpen = ref(false) | ||
const placeId = ref(null) | ||
const isSeries = ref(false) | ||
|
||
const newActivity = ref({}) | ||
const newSeries = ref({}) | ||
|
||
const { | ||
groupId, | ||
} = useCurrentGroupService() | ||
|
||
const { | ||
getPlacesByGroup, | ||
} = usePlaceService() | ||
|
||
const { | ||
getPlaceTypeById, | ||
} = usePlaceTypeService() | ||
|
||
const places = computed(() => getPlacesByGroup(groupId).filter(place => place.status === 'active')) | ||
|
||
const { | ||
mutate: saveNewActivity, | ||
status: createActivityStatus, | ||
reset: resetNewActivity, | ||
} = useCreateActivityMutation({ | ||
onSuccess () { | ||
isOpen.value = false | ||
}, | ||
}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I did use this pattern in places (passing the opts like const {
mutateAsync: createActivity,
status: createActivityStatus,
reset: resetNewActivity,
} = useCreateActivityMutation()
async function saveNewActivity (data) {
await createActivity(data)
isOpen.value = false
} ... which can then keep the (Additionally, the non-async There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (... not suggesting we need to change the style right now though) |
||
|
||
const { | ||
mutate: saveNewSeries, | ||
reset: resetNewSeries, | ||
status: createSeriesStatus, | ||
} = useCreateActivitySeriesMutation({ | ||
onSuccess () { | ||
isOpen.value = false | ||
}, | ||
}) | ||
|
||
const { | ||
getIconProps, | ||
} = useActivityTypeHelpers() | ||
|
||
const { | ||
getActivityTypesByGroup, | ||
} = useActivityTypeService() | ||
|
||
const activityTypes = computed(() => getActivityTypesByGroup(groupId, { status: 'active' })) | ||
|
||
watch(placeId, id => { | ||
// replace object to trigger 'value' watcher in editMixin | ||
newActivity.value = { | ||
...newActivity.value, | ||
place: unref(id), | ||
tiltec marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
newSeries.value = { | ||
...newSeries.value, | ||
place: unref(id), | ||
} | ||
}) | ||
|
||
function selectActivityTypeAndOpen (activityType) { | ||
createNewActivity(activityType.id) | ||
createNewSeries(activityType.id) | ||
resetNewSeries() | ||
resetNewActivity() | ||
isOpen.value = true | ||
} | ||
|
||
function createNewActivity (activityType) { | ||
const date = addHours(startOfTomorrow(), 10) // default to 10am tomorrow | ||
newActivity.value = { | ||
activityType, | ||
participantTypes: [ | ||
{ | ||
role: 'member', | ||
maxParticipants: 2, | ||
description: '', | ||
}, | ||
], | ||
maxParticipants: 2, | ||
description: '', | ||
place: unref(placeId), | ||
date, | ||
dateEnd: addSeconds(date, defaultDuration), | ||
hasDuration: false, | ||
} | ||
} | ||
|
||
function createNewSeries (activityType) { | ||
newSeries.value = { | ||
activityType, | ||
participantTypes: [ | ||
{ | ||
role: 'member', | ||
maxParticipants: 2, | ||
description: '', | ||
}, | ||
], | ||
maxParticipants: 2, | ||
description: '', | ||
startDate: addHours(startOfTomorrow(), 10), | ||
duration: null, | ||
place: unref(placeId), | ||
rule: { | ||
isCustom: false, | ||
byDay: ['MO'], | ||
freq: 'WEEKLY', | ||
}, | ||
} | ||
} | ||
</script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't love the non-round-ness of this button, the "place edit" circular button looks nicer to me. I don't think this needs changing though, would make more sense as a general UI-rationalization.