feat(reactions): add reacting to tasks in the frontend

This commit is contained in:
kolaente 2024-03-12 13:20:43 +01:00
parent fae7e8e649
commit 23e5f2478d
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
13 changed files with 293 additions and 2 deletions

View File

@ -123,6 +123,7 @@
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.10.1",
"vue-router": "4.3.0",
"vuemoji-picker": "^0.2.1",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3"
},

View File

@ -229,6 +229,9 @@ dependencies:
vue-router:
specifier: 4.3.0
version: 4.3.0(vue@3.4.21)
vuemoji-picker:
specifier: ^0.2.1
version: 0.2.1(vue@3.4.21)
workbox-precaching:
specifier: 7.0.0
version: 7.0.0
@ -4358,7 +4361,7 @@ packages:
/@vueuse/shared@9.13.0(vue@3.4.21):
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
dependencies:
vue-demi: 0.14.6(vue@3.4.21)
vue-demi: 0.14.7(vue@3.4.21)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@ -5610,6 +5613,10 @@ packages:
resolution: {integrity: sha512-yDYeobbTEe4TNooEzOQO6xFqg9XnAkVy2Lod1C1B2it8u47JNLYvl9nLDWBamqUakWB8Jc1hhS1uHUNYTNQdfw==}
dev: true
/emoji-picker-element@1.21.1:
resolution: {integrity: sha512-XO3buLicIjIb59dy3R2PVzpyxUEye7DSmHApbxFJxK8gCFPlGKP/Pld8ccWNYvny9t6vYhnKP1FNYgqqMy1XHA==}
dev: false
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true
@ -10273,6 +10280,20 @@ packages:
'@vue/shared': 3.4.21
typescript: 5.4.2
/vuemoji-picker@0.2.1(vue@3.4.21):
resolution: {integrity: sha512-wKRZBZclTdnQIT4jPzmkJ5Ci9ObzMFPjkuYb+/+/9h+mAZIUwdcPqYbEJCohbxJPoOvkuPVDeuOdTKR8hqqVLA==}
peerDependencies:
'@vue/composition-api': ^1.7.0
vue: ^2.6.14 || ^3.2.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
dependencies:
emoji-picker-element: 1.21.1
vue: 3.4.21(typescript@5.4.2)
vue-demi: 0.14.7(vue@3.4.21)
dev: false
/w3c-keyname@2.2.6:
resolution: {integrity: sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,185 @@
<script setup lang="ts">
import type {IReactionPerEntity, ReactionKind} from '@/modelTypes/IReaction'
import {VuemojiPicker} from 'vuemoji-picker'
import ReactionService from '@/services/reactions'
import ReactionModel from '@/models/reaction'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
import {useI18n} from 'vue-i18n'
import {nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {useAuthStore} from '@/stores/auth'
const {
entityKind,
entityId,
} = defineProps<{
entityKind: ReactionKind,
entityId: number,
}>()
const authStore = useAuthStore()
const {t} = useI18n()
const reactionService = new ReactionService()
const model = defineModel<IReactionPerEntity>()
async function addReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.create(reaction)
showEmojiPicker.value = false
if (typeof model.value === 'undefined') {
model.value = {}
}
if (typeof model.value[reaction.value] === 'undefined') {
model.value[reaction.value] = [authStore.info]
} else {
model.value[reaction.value].push(authStore.info)
}
}
async function removeReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.delete(reaction)
showEmojiPicker.value = false
const userIndex = model.value[reaction.value].findIndex(u => u.id === authStore.info?.id)
if (userIndex !== -1) {
model.value[reaction.value].splice(userIndex, 1)
}
if(model.value[reaction.value].length === 0) {
delete model.value[reaction.value]
}
}
function getReactionTooltip(users: IUser[], value: string) {
const names = users.map(u => getDisplayName(u))
if (names.length === 1) {
return t('reaction.reactedWith', {user: names[0], value})
}
if (names.length > 1 && names.length < 10) {
return t('reaction.reactedWithAnd', {
users: names.slice(0, names.length - 1).join(', '),
lastUser: names[names.length - 1],
value,
})
}
return t('reaction.reactedWithAndMany', {
users: names.slice(0, 10).join(', '),
num: names.length - 10,
value,
})
}
const showEmojiPicker = ref(false)
const emojiPickerRef = ref<HTMLElement | null>(null)
function hideEmojiPicker(e: MouseEvent) {
if (showEmojiPicker.value) {
closeWhenClickedOutside(e, emojiPickerRef.value, () => showEmojiPicker.value = false)
}
}
onMounted(() => document.addEventListener('click', hideEmojiPicker))
onBeforeUnmount(() => document.removeEventListener('click', hideEmojiPicker))
const emojiPickerButtonRef = ref<HTMLElement | null>(null)
const reactionContainerRef = ref<HTMLElement | null>(null)
const emojiPickerPosition = ref()
function toggleEmojiPicker() {
if (!showEmojiPicker.value) {
const rect = emojiPickerButtonRef.value?.$el.getBoundingClientRect()
const container = reactionContainerRef.value?.getBoundingClientRect()
const left = rect.left - container.left + rect.width
emojiPickerPosition.value = {
left: left === 0 ? undefined : left,
}
}
nextTick(() => showEmojiPicker.value = !showEmojiPicker.value)
}
function hasCurrentUserReactedWithEmoji(value: string): boolean {
const user = model.value[value].find(u => u.id === authStore.info.id)
return typeof user !== 'undefined'
}
async function toggleReaction(value: string) {
if (hasCurrentUserReactedWithEmoji(value)) {
return removeReaction(value)
}
return addReaction(value)
}
</script>
<template>
<div class="reactions" ref="reactionContainerRef">
<BaseButton
v-for="(users, value) in (model as IReactionPerEntity)"
v-tooltip="getReactionTooltip(users, value)"
class="reaction-button"
:class="{'current-user-has-reacted': hasCurrentUserReactedWithEmoji(value)}"
@click="toggleReaction(value)"
>
{{ value }} {{ users.length }}
</BaseButton>
<BaseButton
class="reaction-button"
@click.stop="toggleEmojiPicker"
v-tooltip="$t('reaction.add')"
ref="emojiPickerButtonRef"
>
<icon :icon="['far', 'face-laugh']"/>
</BaseButton>
<CustomTransition name="fade">
<VuemojiPicker
v-if="showEmojiPicker"
class="emoji-picker"
:style="{left: emojiPickerPosition?.left + 'px'}"
@emojiClick.stop="detail => addReaction(detail.unicode)"
ref="emojiPickerRef"
/>
</CustomTransition>
</div>
</template>
<style scoped lang="scss">
.reaction-button {
margin-right: .25rem;
margin-bottom: .25rem;
padding: .175rem .5rem .15rem;
border: 1px solid var(--grey-400);
background: var(--grey-100);
border-radius: 100px;
font-size: .75rem;
&.current-user-has-reacted {
border-color: var(--primary);
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-light-l), 0.5);
}
}
.emoji-picker {
position: absolute;
z-index: 99;
margin-top: .5rem;
}
</style>

View File

@ -87,7 +87,7 @@ import {
faStar,
faSun,
faTimesCircle,
faCircleQuestion,
faCircleQuestion, faFaceLaugh,
} from '@fortawesome/free-regular-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
@ -186,6 +186,7 @@ library.add(faXmarksLines)
library.add(faFont)
library.add(faRulerHorizontal)
library.add(faUnderline)
library.add(faFaceLaugh)
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -1096,6 +1096,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Error",
"success": "Success",

View File

@ -0,0 +1,14 @@
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {IUser} from '@/modelTypes/IUser'
export type ReactionKind = 'tasks' | 'comments'
export interface IReaction extends IAbstract {
id: number
kind: ReactionKind
value: string
}
export interface IReactionPerEntity {
[reaction: string]: IUser[]
}

View File

@ -14,6 +14,7 @@ import type {IRepeatMode} from '@/types/IRepeatMode'
import type {PartialWithId} from '@/types/PartialWithId'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import type {IReactionPerEntity} from '@/modelTypes/IReaction'
export interface ITask extends IAbstract {
id: number
@ -45,6 +46,8 @@ export interface ITask extends IAbstract {
position: number
kanbanPosition: number
reactions: IReactionPerEntity
createdBy: IUser
created: Date

View File

@ -0,0 +1,14 @@
import type {IReaction} from '@/modelTypes/IReaction'
import AbstractModel from '@/models/abstractModel'
export default class ReactionModel extends AbstractModel<IReaction> implements IReaction {
id: number = 0
kind: 'tasks' | 'comments' = 'tasks'
value: string = ''
constructor(data: Partial<IReaction>) {
super()
this.assignData(data)
}
}

View File

@ -86,6 +86,8 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
position = 0
kanbanPosition = 0
reactions = {}
createdBy: IUser = UserModel
created: Date = null
@ -148,6 +150,12 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
this.updated = new Date(this.updated)
this.projectId = Number(this.projectId)
// We can't convert emojis to camel case, hence we do this manually
this.reactions = {}
Object.keys(data.reactions || {}).forEach(reaction => {
this.reactions[reaction] = data.reactions[reaction].map(u => new UserModel(u))
})
}
getTextIdentifier() {

View File

@ -0,0 +1,27 @@
import AbstractService from '@/services/abstractService'
import type {IAbstract} from '@/modelTypes/IAbstract'
import ReactionModel from '@/models/reaction'
import type {IReactionPerEntity} from '@/modelTypes/IReaction'
import UserModel from '@/models/user'
export default class ReactionService extends AbstractService {
constructor() {
super({
getAll: '{kind}/{id}/reactions',
create: '{kind}/{id}/reactions',
delete: '{kind}/{id}/reactions',
})
}
modelFactory(data: Partial<IAbstract>): ReactionModel {
return new ReactionModel(data)
}
modelGetAllFactory(data: Partial<IReactionPerEntity>): Partial<IReactionPerEntity> {
Object.keys(data).forEach(reaction => {
data[reaction] = data[reaction]?.map(u => new UserModel(u))
})
return data
}
}

View File

@ -6,6 +6,7 @@ import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK} from '@/constants/date'
import UserModel from '@/models/user'
const parseDate = date => {
if (date) {

View File

@ -312,6 +312,14 @@
@update:modelValue="Object.assign(task, $event)"
/>
</div>
<!-- Reactions -->
<Reactions
entity-kind="tasks"
:entity-id="task.id"
v-model="task.reactions"
class="details"
/>
<!-- Attachments -->
<div
@ -616,6 +624,7 @@ import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import Reactions from '@/components/input/Reactions.vue'
const {
taskId,