feat(reactions): add reacting to tasks in the frontend
This commit is contained in:
parent
fae7e8e649
commit
23e5f2478d
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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==}
|
||||
|
||||
|
|
1
frontend/public/emoji.json
Normal file
1
frontend/public/emoji.json
Normal file
File diff suppressed because one or more lines are too long
185
frontend/src/components/input/Reactions.vue
Normal file
185
frontend/src/components/input/Reactions.vue
Normal 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>
|
|
@ -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
|
|
@ -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",
|
||||
|
|
14
frontend/src/modelTypes/IReaction.ts
Normal file
14
frontend/src/modelTypes/IReaction.ts
Normal 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[]
|
||||
}
|
|
@ -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
|
||||
|
|
14
frontend/src/models/reaction.ts
Normal file
14
frontend/src/models/reaction.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
27
frontend/src/services/reactions.ts
Normal file
27
frontend/src/services/reactions.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue
Block a user