forked from vikunja/vikunja
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: vikunja/vikunja#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) {
|
||||
r.UserID = a.GetID()
|
||||
|
||||
exists, err := s.Where("user_id = ? AND entity_id = ? AND entity_kind = ? AND value = ?", r.UserID, r.EntityID, r.EntityKind, r.Value).
|
||||
Exist(&Reaction{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.Insert(r)
|
||||
return
|
||||
}
|
81
pkg/models/reaction_rights.go
Normal file
81
pkg/models/reaction_rights.go
Normal file
@ -0,0 +1,81 @@
|
||||
// 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 (
|
||||
"code.vikunja.io/web"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func (r *Reaction) setEntityKindFromString() (err error) {
|
||||
switch r.EntityKindString {
|
||||
case "tasks":
|
||||
r.EntityKind = ReactionKindTask
|
||||
return
|
||||
case "comments":
|
||||
r.EntityKind = ReactionKindComment
|
||||
return
|
||||
}
|
||||
|
||||
return ErrInvalidReactionEntityKind{
|
||||
Kind: r.EntityKindString,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reaction) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
|
||||
t, err := r.getTask(s)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return t.CanRead(s, a)
|
||||
}
|
||||
|
||||
func (r *Reaction) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
|
||||
t, err := r.getTask(s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return t.CanUpdate(s, a)
|
||||
}
|
||||
|
||||
func (r *Reaction) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
||||
t, err := r.getTask(s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return t.CanUpdate(s, a)
|
||||
}
|
||||
|
||||
func (r *Reaction) getTask(s *xorm.Session) (t *Task, err error) {
|
||||
err = r.setEntityKindFromString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
t = &Task{ID: r.EntityID}
|
||||
|
||||
if r.EntityKind == ReactionKindComment {
|
||||
tc := &TaskComment{ID: r.EntityID}
|
||||
err = getTaskCommentSimple(s, tc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
t.ID = tc.TaskID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
217
pkg/models/reaction_test.go
Normal file
217
pkg/models/reaction_test.go
Normal file
@ -0,0 +1,217 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReaction_ReadAll(t *testing.T) {
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
r := &Reaction{
|
||||
EntityID: 1,
|
||||
EntityKindString: "tasks",
|
||||
}
|
||||
|
||||
reactions, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, ReactionMap{}, reactions)
|
||||
|
||||
reactionMap := reactions.(ReactionMap)
|
||||
assert.Len(t, reactionMap["👋"], 1)
|
||||
assert.Equal(t, int64(1), reactionMap["👋"][0].ID)
|
||||
})
|
||||
t.Run("invalid entity", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 1,
|
||||
EntityKindString: "loremipsum",
|
||||
}
|
||||
|
||||
_, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInvalidReactionEntityKind{Kind: "loremipsum"})
|
||||
})
|
||||
t.Run("no access to task", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
r := &Reaction{
|
||||
EntityID: 34,
|
||||
EntityKindString: "tasks",
|
||||
}
|
||||
|
||||
_, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("nonexistant task", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 9999999,
|
||||
EntityKindString: "tasks",
|
||||
}
|
||||
|
||||
_, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTaskDoesNotExist{ID: r.EntityID})
|
||||
})
|
||||
t.Run("no access to comment", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
r := &Reaction{
|
||||
EntityID: 18,
|
||||
EntityKindString: "comments",
|
||||
}
|
||||
|
||||
_, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("nonexistant comment", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 9999999,
|
||||
EntityKindString: "comments",
|
||||
}
|
||||
|
||||
_, _, _, err := r.ReadAll(s, u, "", 0, 0)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTaskCommentDoesNotExist{ID: r.EntityID})
|
||||
})
|
||||
}
|
||||
|
||||
func TestReaction_Create(t *testing.T) {
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 1,
|
||||
EntityKindString: "tasks",
|
||||
Value: "🦙",
|
||||
}
|
||||
|
||||
can, err := r.CanCreate(s, u)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, can)
|
||||
|
||||
err = r.Create(s, u)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Commit()
|
||||
require.NoError(t, err)
|
||||
|
||||
db.AssertExists(t, "reactions", map[string]interface{}{
|
||||
"entity_id": r.EntityID,
|
||||
"entity_kind": ReactionKindTask,
|
||||
"user_id": u.ID,
|
||||
"value": r.Value,
|
||||
}, false)
|
||||
})
|
||||
t.Run("no permission to access task", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 34,
|
||||
EntityKindString: "tasks",
|
||||
Value: "🦙",
|
||||
}
|
||||
|
||||
can, err := r.CanCreate(s, u)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, can)
|
||||
})
|
||||
t.Run("no permission to access comment", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
r := &Reaction{
|
||||
EntityID: 18,
|
||||
EntityKindString: "comments",
|
||||
Value: "🦙",
|
||||
}
|
||||
|
||||
can, err := r.CanCreate(s, u)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, can)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReaction_Delete(t *testing.T) {
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
r := &Reaction{
|
||||
EntityID: 1,
|
||||
EntityKindString: "tasks",
|
||||
Value: "👋",
|
||||
}
|
||||
|
||||
can, err := r.CanDelete(s, u)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, can)
|
||||
|
||||
err = r.Delete(s, u)
|
||||
require.NoError(t, err)
|
||||
|
||||
db.AssertMissing(t, "reactions", map[string]interface{}{
|
||||
"entity_id": r.EntityID,
|
||||
"entity_kind": ReactionKindTask,
|
||||
"value": "👋",
|
||||
})
|
||||
})
|
||||
}
|
@ -98,6 +98,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
BucketID: 1,
|
||||
IsFavorite: true,
|
||||
Position: 2,
|
||||
Reactions: ReactionMap{
|
||||
"👋": []*user.User{user1},
|
||||
},
|
||||
Labels: []*Label{
|
||||
label4,
|
||||
},
|
||||
|
@ -37,6 +37,8 @@ type TaskComment struct {
|
||||
Author *user.User `xorm:"-" json:"author"`
|
||||
TaskID int64 `xorm:"not null" json:"-" param:"task"`
|
||||
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions"`
|
||||
|
||||
Created time.Time `xorm:"created" json:"created"`
|
||||
Updated time.Time `xorm:"updated" json:"updated"`
|
||||
|
||||
@ -167,7 +169,7 @@ func (tc *TaskComment) Update(s *xorm.Session, _ web.Auth) error {
|
||||
|
||||
func getTaskCommentSimple(s *xorm.Session, tc *TaskComment) error {
|
||||
exists, err := s.
|
||||
Where("id = ? and task_id = ?", tc.ID, tc.TaskID).
|
||||
Where("id = ?", tc.ID).
|
||||
NoAutoCondition().
|
||||
Get(tc)
|
||||
if err != nil {
|
||||
@ -263,8 +265,10 @@ func (tc *TaskComment) ReadAll(s *xorm.Session, auth web.Auth, search string, pa
|
||||
}
|
||||
|
||||
var authorIDs []int64
|
||||
var commentIDs []int64
|
||||
for _, comment := range comments {
|
||||
authorIDs = append(authorIDs, comment.AuthorID)
|
||||
commentIDs = append(commentIDs, comment.ID)
|
||||
}
|
||||
|
||||
authors, err := getUsersOrLinkSharesFromIDs(s, authorIDs)
|
||||
@ -272,8 +276,17 @@ func (tc *TaskComment) ReadAll(s *xorm.Session, auth web.Auth, search string, pa
|
||||
return
|
||||
}
|
||||
|
||||
reactions, err := getReactionsForEntityIDs(s, ReactionKindComment, commentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Author = authors[comment.AuthorID]
|
||||
r, has := reactions[comment.ID]
|
||||
if has {
|
||||
comment.Reactions = r
|
||||
}
|
||||
}
|
||||
|
||||
numberOfTotalItems, err = s.
|
||||
|
@ -125,6 +125,9 @@ type Task struct {
|
||||
// The position of tasks in the kanban board. See the docs for the `position` property on how to use this.
|
||||
KanbanPosition float64 `xorm:"double null" json:"kanban_position"`
|
||||
|
||||
// Reactions on that task.
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions"`
|
||||
|
||||
// The user who initially created the task.
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"` // ID of the user who put that task on the project
|
||||
@ -584,6 +587,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e
|
||||
return err
|
||||
}
|
||||
|
||||
reactions, err := getReactionsForEntityIDs(s, ReactionKindTask, taskIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add all objects to their tasks
|
||||
for _, task := range taskMap {
|
||||
|
||||
@ -600,6 +608,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth) (e
|
||||
task.setIdentifier(projects[task.ProjectID])
|
||||
|
||||
task.IsFavorite = taskFavorites[task.ID]
|
||||
|
||||
r, has := reactions[task.ID]
|
||||
if has {
|
||||
task.Reactions = r
|
||||
}
|
||||
}
|
||||
|
||||
// Get all related tasks
|
||||
|
@ -65,6 +65,7 @@ func SetupTests() {
|
||||
"subscriptions",
|
||||
"favorites",
|
||||
"api_tokens",
|
||||
"reactions",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -25,6 +25,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/adlio/trello"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
@ -589,6 +589,15 @@ func registerAPIRoutes(a *echo.Group) {
|
||||
a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb)
|
||||
a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents)
|
||||
}
|
||||
|
||||
reactionProvider := &handler.WebHandler{
|
||||
EmptyStruct: func() handler.CObject {
|
||||
return &models.Reaction{}
|
||||
},
|
||||
}
|
||||
a.GET("/:entitykind/:entityid/reactions", reactionProvider.ReadAllWeb)
|
||||
a.POST("/:entitykind/:entityid/reactions/delete", reactionProvider.DeleteWeb)
|
||||
a.PUT("/:entitykind/:entityid/reactions", reactionProvider.CreateWeb)
|
||||
}
|
||||
|
||||
func registerMigrations(m *echo.Group) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
// Package swagger Code generated by swaggo/swag. DO NOT EDIT
|
||||
// Code generated by swaggo/swag. DO NOT EDIT.
|
||||
|
||||
package swagger
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
@ -7042,6 +7043,190 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/{kind}/{id}/reactions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns all reactions for an entity",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Get all reactions for an entity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either ` + "`" + `tasks` + "`" + ` or ` + "`" + `comments` + "`" + ` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The reactions",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.ReactionMap"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Add a reaction to an entity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either ` + "`" + `tasks` + "`" + ` or ` + "`" + `comments` + "`" + ` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "The reaction you want to add to the entity.",
|
||||
"name": "project",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The created reaction",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Removes the reaction of that user on that entity.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Removes the user's reaction",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either ` + "`" + `tasks` + "`" + ` or ` + "`" + `comments` + "`" + ` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "The reaction you want to add to the entity.",
|
||||
"name": "project",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The reaction was successfully removed.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/{username}/avatar": {
|
||||
"get": {
|
||||
"description": "Returns the user avatar as image.",
|
||||
@ -7754,6 +7939,36 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Reaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"description": "A timestamp when this reaction was created. You cannot change this value.",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "The user who reacted",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user.User"
|
||||
}
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ReactionMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user.User"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RelatedTaskMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@ -8975,8 +9190,6 @@ var SwaggerInfo = &swag.Spec{
|
||||
Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer <jwt-token>`-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer <token>` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n<!-- ReDoc-Inject: <security-definitions> -->",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
@ -7034,6 +7034,190 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/{kind}/{id}/reactions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns all reactions for an entity",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Get all reactions for an entity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either `tasks` or `comments` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The reactions",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/models.ReactionMap"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Add a reaction to an entity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either `tasks` or `comments` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "The reaction you want to add to the entity.",
|
||||
"name": "project",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The created reaction",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Removes the reaction of that user on that entity.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "Removes the user's reaction",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Entity ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "The kind of the entity. Can be either `tasks` or `comments` for task comments",
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "The reaction you want to add to the entity.",
|
||||
"name": "project",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Reaction"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The reaction was successfully removed.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the entity",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.HTTPError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/{username}/avatar": {
|
||||
"get": {
|
||||
"description": "Returns the user avatar as image.",
|
||||
@ -7746,6 +7930,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Reaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"description": "A timestamp when this reaction was created. You cannot change this value.",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "The user who reacted",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user.User"
|
||||
}
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.ReactionMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user.User"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.RelatedTaskMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
@ -509,6 +509,27 @@ definitions:
|
||||
description: The username.
|
||||
type: string
|
||||
type: object
|
||||
models.Reaction:
|
||||
properties:
|
||||
created:
|
||||
description: A timestamp when this reaction was created. You cannot change
|
||||
this value.
|
||||
type: string
|
||||
user:
|
||||
allOf:
|
||||
- $ref: '#/definitions/user.User'
|
||||
description: The user who reacted
|
||||
value:
|
||||
description: The actual reaction. This can be any valid utf character or text,
|
||||
up to a length of 20.
|
||||
type: string
|
||||
type: object
|
||||
models.ReactionMap:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: '#/definitions/user.User'
|
||||
type: array
|
||||
type: object
|
||||
models.RelatedTaskMap:
|
||||
additionalProperties:
|
||||
items:
|
||||
@ -1439,6 +1460,128 @@ info:
|
||||
url: https://code.vikunja.io/api/src/branch/main/LICENSE
|
||||
title: Vikunja API
|
||||
paths:
|
||||
/{kind}/{id}/reactions:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Removes the reaction of that user on that entity.
|
||||
parameters:
|
||||
- description: Entity ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: The kind of the entity. Can be either `tasks` or `comments` for
|
||||
task comments
|
||||
in: path
|
||||
name: kind
|
||||
required: true
|
||||
type: integer
|
||||
- description: The reaction you want to add to the entity.
|
||||
in: body
|
||||
name: project
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Reaction'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The reaction was successfully removed.
|
||||
schema:
|
||||
$ref: '#/definitions/models.Message'
|
||||
"403":
|
||||
description: The user does not have access to the entity
|
||||
schema:
|
||||
$ref: '#/definitions/web.HTTPError'
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Message'
|
||||
security:
|
||||
- JWTKeyAuth: []
|
||||
summary: Removes the user's reaction
|
||||
tags:
|
||||
- task
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns all reactions for an entity
|
||||
parameters:
|
||||
- description: Entity ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: The kind of the entity. Can be either `tasks` or `comments` for
|
||||
task comments
|
||||
in: path
|
||||
name: kind
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The reactions
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.ReactionMap'
|
||||
type: array
|
||||
"403":
|
||||
description: The user does not have access to the entity
|
||||
schema:
|
||||
$ref: '#/definitions/web.HTTPError'
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Message'
|
||||
security:
|
||||
- JWTKeyAuth: []
|
||||
summary: Get all reactions for an entity
|
||||
tags:
|
||||
- task
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Entity ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: The kind of the entity. Can be either `tasks` or `comments` for
|
||||
task comments
|
||||
in: path
|
||||
name: kind
|
||||
required: true
|
||||
type: integer
|
||||
- description: The reaction you want to add to the entity.
|
||||
in: body
|
||||
name: project
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Reaction'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The created reaction
|
||||
schema:
|
||||
$ref: '#/definitions/models.Reaction'
|
||||
"403":
|
||||
description: The user does not have access to the entity
|
||||
schema:
|
||||
$ref: '#/definitions/web.HTTPError'
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
$ref: '#/definitions/models.Message'
|
||||
security:
|
||||
- JWTKeyAuth: []
|
||||
summary: Add a reaction to an entity
|
||||
tags:
|
||||
- task
|
||||
/{username}/avatar:
|
||||
get:
|
||||
description: Returns the user avatar as image.
|
||||
|
@ -34,6 +34,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
@ -259,13 +260,17 @@ func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) {
|
||||
|
||||
// GetUsersByIDs returns a map of users from a slice of user ids
|
||||
func GetUsersByIDs(s *xorm.Session, userIDs []int64) (users map[int64]*User, err error) {
|
||||
users = make(map[int64]*User)
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
err = s.In("id", userIDs).Find(&users)
|
||||
return GetUsersByCond(s, builder.In("id", userIDs))
|
||||
}
|
||||
|
||||
func GetUsersByCond(s *xorm.Session, cond builder.Cond) (users map[int64]*User, err error) {
|
||||
users = make(map[int64]*User)
|
||||
|
||||
err = s.Where(cond).Find(&users)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user