feat: emoji reactions for tasks and comments (#2196)
This PR adds reactions for tasks and comments, similar to what you can do on Gitea, GitHub, Slack and plenty of other tools. Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2196 Co-authored-by: kolaente <k@knt.li> Co-committed-by: kolaente <k@knt.li>
This commit is contained in:
@ -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"
|
||||
},
|
||||
@ -184,7 +185,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
|
||||
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch",
|
||||
"@github/hotkey@3.1.0": "patches/@github__hotkey@3.1.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
28
frontend/patches/@github__hotkey@3.1.0.patch
Normal file
28
frontend/patches/@github__hotkey@3.1.0.patch
Normal file
@ -0,0 +1,28 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index b6e6e0a6864cb00bc085b8d4503a705cb3bc8404..0466ef46406b0df41c8d0bb9a5bac9eabf4a50de 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -368,10 +368,12 @@ const sequenceTracker = new SequenceTracker({
|
||||
function keyDownHandler(event) {
|
||||
if (event.defaultPrevented)
|
||||
return;
|
||||
- if (!(event.target instanceof Node))
|
||||
+ const target = event.explicitOriginalTarget || event.target;
|
||||
+ if (target.shadowRoot)
|
||||
return;
|
||||
- if (isFormField(event.target)) {
|
||||
- const target = event.target;
|
||||
+ if (!(target instanceof Node))
|
||||
+ return;
|
||||
+ if (isFormField(target)) {
|
||||
if (!target.id)
|
||||
return;
|
||||
if (!target.ownerDocument.querySelector(`[data-hotkey-scope="${target.id}"]`))
|
||||
@@ -385,7 +387,6 @@ function keyDownHandler(event) {
|
||||
sequenceTracker.registerKeypress(event);
|
||||
currentTriePosition = newTriePosition;
|
||||
if (newTriePosition instanceof Leaf) {
|
||||
- const target = event.target;
|
||||
let shouldFire = false;
|
||||
let elementToFire;
|
||||
const formField = isFormField(target);
|
31
frontend/pnpm-lock.yaml
generated
31
frontend/pnpm-lock.yaml
generated
@ -5,6 +5,9 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@github/hotkey@3.1.0':
|
||||
hash: c67tdk7qpd5grxd2zj6lsxfbou
|
||||
path: patches/@github__hotkey@3.1.0.patch
|
||||
flexsearch@0.7.31:
|
||||
hash: bfn3sngfuhktmdj7jgl3ejl35y
|
||||
path: patches/flexsearch@0.7.31.patch
|
||||
@ -24,7 +27,7 @@ dependencies:
|
||||
version: 3.0.6(@fortawesome/fontawesome-svg-core@6.5.1)(vue@3.4.21)
|
||||
'@github/hotkey':
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
version: 3.1.0(patch_hash=c67tdk7qpd5grxd2zj6lsxfbou)
|
||||
'@infectoone/vue-ganttastic':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(dayjs@1.11.10)(vue@3.4.21)
|
||||
@ -229,6 +232,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
|
||||
@ -2687,9 +2693,10 @@ packages:
|
||||
vue: 3.4.21(typescript@5.4.2)
|
||||
dev: false
|
||||
|
||||
/@github/hotkey@3.1.0:
|
||||
/@github/hotkey@3.1.0(patch_hash=c67tdk7qpd5grxd2zj6lsxfbou):
|
||||
resolution: {integrity: sha512-Lj9QjYa+b+Nk5U1nZtlXLdx3HI8/EeM6ZNwBjpYcGVYqpwHdM2ScRH0p7+5zh28JG6SPbTM9+Rb1dFd742qMTw==}
|
||||
dev: false
|
||||
patched: true
|
||||
|
||||
/@hapi/hoek@9.2.1:
|
||||
resolution: {integrity: sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==}
|
||||
@ -4358,7 +4365,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 +5617,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 +10284,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/emojis.json
Normal file
1
frontend/public/emojis.json
Normal file
File diff suppressed because one or more lines are too long
193
frontend/src/components/input/Reactions.vue
Normal file
193
frontend/src/components/input/Reactions.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<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'
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
|
||||
const {
|
||||
entityKind,
|
||||
entityId,
|
||||
} = defineProps<{
|
||||
entityKind: ReactionKind,
|
||||
entityId: number,
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const {t} = useI18n()
|
||||
const reactionService = new ReactionService()
|
||||
const {isDark} = useColorScheme()
|
||||
|
||||
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.$el, () => 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
|
||||
ref="reactionContainerRef"
|
||||
class="reactions"
|
||||
>
|
||||
<BaseButton
|
||||
v-for="(users, value) in (model as IReactionPerEntity)"
|
||||
:key="'button' + value"
|
||||
v-tooltip="getReactionTooltip(users, value)"
|
||||
class="reaction-button"
|
||||
:class="{'current-user-has-reacted': hasCurrentUserReactedWithEmoji(value)}"
|
||||
@click="toggleReaction(value)"
|
||||
>
|
||||
{{ value }} {{ users.length }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
ref="emojiPickerButtonRef"
|
||||
v-tooltip="$t('reaction.add')"
|
||||
class="reaction-button"
|
||||
@click.stop="toggleEmojiPicker"
|
||||
>
|
||||
<icon :icon="['far', 'face-laugh']" />
|
||||
</BaseButton>
|
||||
<CustomTransition name="fade">
|
||||
<VuemojiPicker
|
||||
v-if="showEmojiPicker"
|
||||
ref="emojiPickerRef"
|
||||
class="emoji-picker"
|
||||
:style="{left: emojiPickerPosition?.left + 'px'}"
|
||||
data-source="/emojis.json"
|
||||
:is-dark="isDark"
|
||||
@emojiClick="detail => addReaction(detail.unicode)"
|
||||
/>
|
||||
</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>
|
@ -558,6 +558,10 @@ function handleImagePaste(event) {
|
||||
|
||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||
function setFocusToEditor(event) {
|
||||
if(event.target.shadowRoot) {
|
||||
return
|
||||
}
|
||||
|
||||
const hotkeyString = eventToHotkeyString(event)
|
||||
if (!hotkeyString) return
|
||||
if (hotkeyString !== editShortcut ||
|
||||
|
@ -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
|
@ -97,6 +97,12 @@
|
||||
editComment()
|
||||
}"
|
||||
/>
|
||||
<Reactions
|
||||
v-model="c.reactions"
|
||||
class="mt-2"
|
||||
entity-kind="comments"
|
||||
:entity-id="c.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -190,6 +196,7 @@ import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
import {getAvatarUrl, getDisplayName} from '@/models/user'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import Reactions from '@/components/input/Reactions.vue'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
|
@ -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
|
||||
|
@ -1,12 +1,15 @@
|
||||
import type {IAbstract} from './IAbstract'
|
||||
import type {IUser} from './IUser'
|
||||
import type {ITask} from './ITask'
|
||||
import type {IReactionPerEntity} from '@/modelTypes/IReaction'
|
||||
|
||||
export interface ITaskComment extends IAbstract {
|
||||
id: number
|
||||
taskId: ITask['id']
|
||||
comment: string
|
||||
author: IUser
|
||||
|
||||
reactions: IReactionPerEntity
|
||||
|
||||
created: Date
|
||||
updated: 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() {
|
||||
|
@ -10,6 +10,8 @@ export default class TaskCommentModel extends AbstractModel<ITaskComment> implem
|
||||
taskId: ITask['id'] = 0
|
||||
comment = ''
|
||||
author: IUser = UserModel
|
||||
|
||||
reactions = {}
|
||||
|
||||
created: Date = null
|
||||
updated: Date = null
|
||||
@ -21,5 +23,11 @@ export default class TaskCommentModel extends AbstractModel<ITaskComment> implem
|
||||
this.author = new UserModel(this.author)
|
||||
this.created = new Date(this.created)
|
||||
this.updated = new Date(this.updated)
|
||||
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -77,19 +77,25 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||
case 'post':
|
||||
if (this.useUpdateInterceptor()) {
|
||||
config.data = this.beforeUpdate(config.data)
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
if(this.autoTransformBeforePost()) {
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'put':
|
||||
if (this.useCreateInterceptor()) {
|
||||
config.data = this.beforeCreate(config.data)
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
if(this.autoTransformBeforePut()) {
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'delete':
|
||||
if (this.useDeleteInterceptor()) {
|
||||
config.data = this.beforeDelete(config.data)
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
if(this.autoTransformBeforeDelete()) {
|
||||
config.data = objectToSnakeCase(config.data)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -119,6 +125,22 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||
useDeleteInterceptor(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
autoTransformBeforeSend(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
autoTransformBeforePost(): boolean {
|
||||
return this.autoTransformBeforeSend()
|
||||
}
|
||||
|
||||
autoTransformBeforePut(): boolean {
|
||||
return this.autoTransformBeforeSend()
|
||||
}
|
||||
|
||||
autoTransformBeforeDelete(): boolean {
|
||||
return this.autoTransformBeforeSend()
|
||||
}
|
||||
|
||||
/////////////////
|
||||
// Helper functions
|
||||
@ -370,6 +392,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||
const cancel = this.setLoading()
|
||||
|
||||
try {
|
||||
console.log('post', model.reactions)
|
||||
const response = await this.http.post(url, model)
|
||||
const result = this.modelUpdateFactory(response.data)
|
||||
if (typeof model.maxRight !== 'undefined') {
|
||||
|
32
frontend/src/services/reactions.ts
Normal file
32
frontend/src/services/reactions.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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/delete',
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async delete(model: IAbstract) {
|
||||
const finalUrl = this.getReplacedRoute(this.paths.delete, model)
|
||||
return super.post(finalUrl, model)
|
||||
}
|
||||
}
|
@ -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 {objectToSnakeCase} from '@/helpers/case'
|
||||
|
||||
const parseDate = date => {
|
||||
if (date) {
|
||||
@ -38,8 +39,12 @@ export default class TaskService extends AbstractService<ITask> {
|
||||
return this.processModel(model)
|
||||
}
|
||||
|
||||
autoTransformBeforePost(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
processModel(updatedModel) {
|
||||
const model = { ...updatedModel }
|
||||
const model = {...updatedModel}
|
||||
|
||||
model.title = model.title?.trim()
|
||||
|
||||
@ -108,7 +113,15 @@ export default class TaskService extends AbstractService<ITask> {
|
||||
model.labels = model.labels.map(l => labelService.processModel(l))
|
||||
}
|
||||
|
||||
return model as ITask
|
||||
const transformed = objectToSnakeCase(model)
|
||||
|
||||
// We can't convert emojis to skane case, hence we add them back again
|
||||
transformed.reactions = {}
|
||||
Object.keys(updatedModel.reactions || {}).forEach(reaction => {
|
||||
transformed.reactions[reaction] = updatedModel.reactions[reaction].map(u => objectToSnakeCase(u))
|
||||
})
|
||||
|
||||
return transformed as ITask
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import AbstractService from './abstractService'
|
||||
import TaskCommentModel from '@/models/taskComment'
|
||||
import type {ITaskComment} from '@/modelTypes/ITaskComment'
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
|
||||
export default class TaskCommentService extends AbstractService<ITaskComment> {
|
||||
constructor() {
|
||||
@ -16,4 +17,22 @@ export default class TaskCommentService extends AbstractService<ITaskComment> {
|
||||
modelFactory(data) {
|
||||
return new TaskCommentModel(data)
|
||||
}
|
||||
|
||||
autoTransformBeforePost(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
beforeUpdate(model: ITaskComment) {
|
||||
const transformed = objectToSnakeCase({...model})
|
||||
|
||||
// We can't convert emojis to skane case, hence we add them back again
|
||||
transformed.reactions = {}
|
||||
Object.keys(model.reactions || {}).forEach(reaction => {
|
||||
transformed.reactions[reaction] = model.reactions[reaction].map(u => objectToSnakeCase(u))
|
||||
})
|
||||
|
||||
console.log()
|
||||
|
||||
return transformed as ITaskComment
|
||||
}
|
||||
}
|
@ -152,6 +152,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
const taskService = new TaskService()
|
||||
try {
|
||||
const updatedTask = await taskService.update(task)
|
||||
console.log({updated: updatedTask.reactions, old: task.reactions})
|
||||
kanbanStore.setTaskInBucket(updatedTask)
|
||||
return updatedTask
|
||||
} finally {
|
||||
|
@ -312,6 +312,14 @@
|
||||
@update:modelValue="Object.assign(task, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Reactions -->
|
||||
<Reactions
|
||||
v-model="task.reactions"
|
||||
entity-kind="tasks"
|
||||
:entity-id="task.id"
|
||||
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,
|
||||
|
Reference in New Issue
Block a user