feat: emoji reactions for tasks and comments (#2196)
continuous-integration/drone/push Build is passing Details

This PR adds reactions for tasks and comments, similar to what you can do on Gitea, GitHub, Slack and plenty of other tools.

Reviewed-on: #2196
Co-authored-by: kolaente <k@knt.li>
Co-committed-by: kolaente <k@knt.li>
This commit is contained in:
kolaente 2024-03-12 19:25:58 +00:00 committed by konrad
parent b9c513f681
commit a5c51d4b1e
43 changed files with 1653 additions and 37 deletions

View File

@ -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

View File

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

View 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);

View File

@ -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==}

File diff suppressed because one or more lines are too long

View 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>

View File

@ -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 ||

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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))
})
}
}

View File

@ -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') {

View 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)
}
}

View File

@ -6,6 +6,7 @@ import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK} from '@/constants/date'
import {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
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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
}

View File

@ -0,0 +1,6 @@
- id: 1
user_id: 1
entity_id: 1
entity_kind: 0
value: '👋'
created: 2024-03-12 15:00:00

View File

@ -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

View File

@ -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

View 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
},
})
}

View File

@ -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
// ============

View File

@ -61,6 +61,7 @@ func GetTables() []interface{} {
&APIToken{},
&TypesenseSync{},
&Webhook{},
&Reaction{},
}
}

View File

@ -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
View 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
}

View 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
View 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": "👋",
})
})
}

View File

@ -98,6 +98,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
BucketID: 1,
IsFavorite: true,
Position: 2,
Reactions: ReactionMap{
"👋": []*user.User{user1},
},
Labels: []*Label{
label4,
},

View File

@ -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.

View File

@ -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

View File

@ -65,6 +65,7 @@ func SetupTests() {
"subscriptions",
"favorites",
"api_tokens",
"reactions",
)
if err != nil {
log.Fatal(err)

View File

@ -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"
)

View File

@ -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) {

View File

@ -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() {

View File

@ -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": {

View File

@ -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.

View File

@ -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
}