feat: emoji reactions for tasks and comments (#2196)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
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: #2196 Co-authored-by: kolaente <k@knt.li> Co-committed-by: kolaente <k@knt.li>
This commit is contained in:
parent
b9c513f681
commit
a5c51d4b1e
@ -97,6 +97,7 @@ This document describes the different errors Vikunja can return.
|
||||
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
|
||||
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
|
||||
| 4024 | 400 | The provided filter expression is invalid. |
|
||||
| 4025 | 400 | The reaction kind is invalid. |
|
||||
|
||||
## Team
|
||||
|
||||
|
@ -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,
|
||||
|
11
pkg/db/db.go
11
pkg/db/db.go
@ -26,6 +26,8 @@ import (
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/names"
|
||||
"xorm.io/xorm/schemas"
|
||||
@ -227,3 +229,12 @@ func NewSession() *xorm.Session {
|
||||
func Type() schemas.DBType {
|
||||
return x.Dialect().URI().DBType
|
||||
}
|
||||
|
||||
func GetDialect() string {
|
||||
dialect := config.DatabaseType.GetString()
|
||||
if dialect == "sqlite" {
|
||||
dialect = builder.SQLITE
|
||||
}
|
||||
|
||||
return dialect
|
||||
}
|
||||
|
6
pkg/db/fixtures/reactions.yml
Normal file
6
pkg/db/fixtures/reactions.yml
Normal file
@ -0,0 +1,6 @@
|
||||
- id: 1
|
||||
user_id: 1
|
||||
entity_id: 1
|
||||
entity_kind: 0
|
||||
value: '👋'
|
||||
created: 2024-03-12 15:00:00
|
@ -100,3 +100,9 @@
|
||||
task_id: 35
|
||||
created: 2020-02-19 18:07:06
|
||||
updated: 2020-02-19 18:07:06
|
||||
- id: 18
|
||||
comment: comment 18
|
||||
author_id: 13
|
||||
task_id: 34
|
||||
created: 2020-02-19 18:07:06
|
||||
updated: 2020-02-19 18:07:06
|
||||
|
@ -115,49 +115,49 @@ func TestTaskCollection(t *testing.T) {
|
||||
t.Run("by priority", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
})
|
||||
t.Run("by priority desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`)
|
||||
})
|
||||
t.Run("by priority asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
})
|
||||
// should equal duedate asc
|
||||
t.Run("by due_date", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("by duedate desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
})
|
||||
// Due date without unix suffix
|
||||
t.Run("by duedate asc without suffix", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("by due_date without suffix", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("by duedate desc without suffix", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
})
|
||||
t.Run("by duedate asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, urlParams)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("invalid sort parameter", func(t *testing.T) {
|
||||
_, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"loremipsum"}}, urlParams)
|
||||
@ -358,33 +358,33 @@ func TestTaskCollection(t *testing.T) {
|
||||
t.Run("by priority", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
})
|
||||
t.Run("by priority desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"desc"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"title":"task #3 high prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":100,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-3","index":3,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":4,"title":"task #4 low prio","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":1`)
|
||||
})
|
||||
t.Run("by priority asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"priority"}, "order_by": []string{"asc"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":33,"title":"task #33 with percent done","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0.5,"identifier":"test1-17","index":17,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":35,"title":"task #35","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":21,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":[{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"labels":[{"id":4,"title":"Label #4 - visible via other task","description":"","hex_color":"","created_by":{"id":2,"name":"","username":"user2","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"},"created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}],"hex_color":"","percent_done":0,"identifier":"test21-1","index":1,"related_tasks":{"related":[{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null},{"id":1,"title":"task #1","description":"Lorem Ipsum","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"","index":1,"related_tasks":null,"attachments":null,"cover_image_attachment_id":0,"is_favorite":true,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":1,"position":2,"kanban_position":0,"reactions":null,"created_by":null}]},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":19,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":39,"title":"task #39","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"0001-01-01T00:00:00Z","reminders":null,"project_id":25,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"#0","index":0,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":0,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}]`)
|
||||
})
|
||||
// should equal duedate asc
|
||||
t.Run("by due_date", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("by duedate desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"desc"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":6,"title":"task #6 lower due date`)
|
||||
})
|
||||
t.Run("by duedate asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"sort_by": []string{"due_date"}, "order_by": []string{"asc"}}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":6,"title":"task #6 lower due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-11-30T22:25:24Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-6","index":6,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":3,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}},{"id":5,"title":"task #5 higher due date","description":"","done":false,"done_at":"0001-01-01T00:00:00Z","due_date":"2018-12-01T03:58:44Z","reminders":null,"project_id":1,"repeat_after":0,"repeat_mode":0,"priority":0,"start_date":"0001-01-01T00:00:00Z","end_date":"0001-01-01T00:00:00Z","assignees":null,"labels":null,"hex_color":"","percent_done":0,"identifier":"test1-5","index":5,"related_tasks":{},"attachments":null,"cover_image_attachment_id":0,"is_favorite":false,"created":"2018-12-01T01:12:04Z","updated":"2018-12-01T01:12:04Z","bucket_id":2,"position":0,"kanban_position":0,"reactions":null,"created_by":{"id":1,"name":"","username":"user1","created":"2018-12-01T15:13:12Z","updated":"2018-12-02T15:13:12Z"}}`)
|
||||
})
|
||||
t.Run("invalid parameter", func(t *testing.T) {
|
||||
// Invalid parameter should not sort at all
|
||||
|
50
pkg/migration/20240311173251.go
Normal file
50
pkg/migration/20240311173251.go
Normal file
@ -0,0 +1,50 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public Licensee as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type reactions20240311173251 struct {
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"reaction"`
|
||||
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
EntityID int64 `xorm:"bigint not null INDEX" json:"entity_id"`
|
||||
EntityKind int `xorm:"bigint not null INDEX" json:"entity_kind"`
|
||||
Value string `xorm:"varchar(20) not null INDEX" json:"value"`
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
}
|
||||
|
||||
func (reactions20240311173251) TableName() string {
|
||||
return "reactions"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20240311173251",
|
||||
Description: "Create reactions table",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync2(reactions20240311173251{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
@ -1060,6 +1060,33 @@ func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrInvalidReactionEntityKind represents an error where the reaction kind is invalid
|
||||
type ErrInvalidReactionEntityKind struct {
|
||||
Kind string
|
||||
}
|
||||
|
||||
// IsErrInvalidReactionEntityKind checks if an error is ErrInvalidReactionEntityKind.
|
||||
func IsErrInvalidReactionEntityKind(err error) bool {
|
||||
_, ok := err.(ErrInvalidReactionEntityKind)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrInvalidReactionEntityKind) Error() string {
|
||||
return fmt.Sprintf("Reaction kind %s is invalid", err.Kind)
|
||||
}
|
||||
|
||||
// ErrCodeInvalidReactionEntityKind holds the unique world-error code of this error
|
||||
const ErrCodeInvalidReactionEntityKind = 4025
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrInvalidReactionEntityKind) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeInvalidReactionEntityKind,
|
||||
Message: fmt.Sprintf("The reaction kind '%s' is invalid.", err.Kind),
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Team errors
|
||||
// ============
|
||||
|
@ -61,6 +61,7 @@ func GetTables() []interface{} {
|
||||
&APIToken{},
|
||||
&TypesenseSync{},
|
||||
&Webhook{},
|
||||
&Reaction{},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
@ -370,10 +369,7 @@ type projectOptions struct {
|
||||
}
|
||||
|
||||
func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search string, getArchived bool) *builder.Builder {
|
||||
dialect := config.DatabaseType.GetString()
|
||||
if dialect == "sqlite" {
|
||||
dialect = builder.SQLITE
|
||||
}
|
||||
dialect := db.GetDialect()
|
||||
|
||||
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
|
||||
var getArchivedCond builder.Cond = builder.Eq{"1": 1}
|
||||
|
191
pkg/models/reaction.go
Normal file
191
pkg/models/reaction.go
Normal file
@ -0,0 +1,191 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public Licensee as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/web"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
type ReactionKind int
|
||||
|
||||
const (
|
||||
ReactionKindTask = iota
|
||||
ReactionKindComment
|
||||
)
|
||||
|
||||
type Reaction struct {
|
||||
// The unique numeric id of this reaction
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"-" param:"reaction"`
|
||||
|
||||
// The user who reacted
|
||||
User *user.User `xorm:"-" json:"user" valid:"-"`
|
||||
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
|
||||
// The id of the entity you're reacting to
|
||||
EntityID int64 `xorm:"bigint not null INDEX" json:"-" param:"entityid"`
|
||||
// The entity kind which you're reacting to. Can be 0 for task, 1 for comment.
|
||||
EntityKind ReactionKind `xorm:"bigint not null INDEX" json:"-"`
|
||||
EntityKindString string `xorm:"-" json:"-" param:"entitykind"`
|
||||
|
||||
// The actual reaction. This can be any valid utf character or text, up to a length of 20.
|
||||
Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required"`
|
||||
|
||||
// A timestamp when this reaction was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Rights `xorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (*Reaction) TableName() string {
|
||||
return "reactions"
|
||||
}
|
||||
|
||||
type ReactionMap map[string][]*user.User
|
||||
|
||||
// ReadAll gets all reactions for an entity
|
||||
// @Summary Get all reactions for an entity
|
||||
// @Description Returns all reactions for an entity
|
||||
// @tags task
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param id path int true "Entity ID"
|
||||
// @Param kind path int true "The kind of the entity. Can be either `tasks` or `comments` for task comments"
|
||||
// @Success 200 {array} models.ReactionMap "The reactions"
|
||||
// @Failure 403 {object} web.HTTPError "The user does not have access to the entity"
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /{kind}/{id}/reactions [get]
|
||||
func (r *Reaction) ReadAll(s *xorm.Session, a web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
|
||||
|
||||
can, _, err := r.CanRead(s, a)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if !can {
|
||||
return nil, 0, 0, ErrGenericForbidden{}
|
||||
}
|
||||
|
||||
reactions, err := getReactionsForEntityIDs(s, r.EntityKind, []int64{r.EntityID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return reactions[r.EntityID], len(reactions[r.EntityID]), int64(len(reactions[r.EntityID])), nil
|
||||
}
|
||||
|
||||
func getReactionsForEntityIDs(s *xorm.Session, entityKind ReactionKind, entityIDs []int64) (reactionsWithTasks map[int64]ReactionMap, err error) {
|
||||
|
||||
where := builder.And(
|
||||
builder.Eq{"entity_kind": entityKind},
|
||||
builder.In("entity_id", entityIDs),
|
||||
)
|
||||
|
||||
reactions := []*Reaction{}
|
||||
err = s.Where(where).Find(&reactions)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(reactions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cond := builder.
|
||||
Select("user_id").
|
||||
From("reactions").
|
||||
Where(where)
|
||||
|
||||
users, err := user.GetUsersByCond(s, builder.In("id", cond))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
reactionsWithTasks = make(map[int64]ReactionMap)
|
||||
for _, reaction := range reactions {
|
||||
if _, taskExists := reactionsWithTasks[reaction.EntityID]; !taskExists {
|
||||
reactionsWithTasks[reaction.EntityID] = make(ReactionMap)
|
||||
}
|
||||
|
||||
if _, has := reactionsWithTasks[reaction.EntityID][reaction.Value]; !has {
|
||||
reactionsWithTasks[reaction.EntityID][reaction.Value] = []*user.User{}
|
||||
}
|
||||
|
||||
reactionsWithTasks[reaction.EntityID][reaction.Value] = append(reactionsWithTasks[reaction.EntityID][reaction.Value], users[reaction.UserID])
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Delete removes the user's own reaction
|
||||
// @Summary Removes the user's reaction
|
||||
// @Description Removes the reaction of that user on that entity.
|
||||
// @tags task
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param id path int true "Entity ID"
|
||||
// @Param kind path int true "The kind of the entity. Can be either `tasks` or `comments` for task comments"
|
||||
// @Param project body models.Reaction true "The reaction you want to add to the entity."
|
||||
// @Success 200 {object} models.Message "The reaction was successfully removed."
|
||||
// @Failure 403 {object} web.HTTPError "The user does not have access to the entity"
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /{kind}/{id}/reactions/delete [post]
|
||||
func (r *Reaction) Delete(s *xorm.Session, a web.Auth) (err error) {
|
||||
r.UserID = a.GetID()
|
||||
|
||||
_, err = s.Where("user_id = ? AND entity_id = ? AND entity_kind = ? AND value = ?", r.UserID, r.EntityID, r.EntityKind, r.Value).
|
||||
Delete(&Reaction{})
|
||||
return
|
||||
}
|
||||
|
||||
// Create adds a new reaction to an entity
|
||||
// @Summary Add a reaction to an entity
|
||||
// @Description Add a reaction to an entity. Will do nothing if the reaction already exists.
|
||||
// @tags task
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param id path int true "Entity ID"
|
||||
// @Param kind path int true "The kind of the entity. Can be either `tasks` or `comments` for task comments"
|
||||
// @Param project body models.Reaction true "The reaction you want to add to the entity."
|
||||
// @Success 200 {object} models.Reaction "The created reaction"
|
||||
// @Failure 403 {object} web.HTTPError "The user does not have access to the entity"
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /{kind}/{id}/reactions [put]
|
||||
func (r *Reaction) Create(s *xorm.Session, a web.Auth) (err error) { |