Compare commits

..

45 Commits

Author SHA1 Message Date
kolaente 212d11ae72
chore(deps): update flake
continuous-integration/drone/pr Build is passing Details
2024-04-07 12:36:54 +02:00
kolaente 51459f9a52
chore: sign drone config 2024-04-07 12:21:45 +02:00
renovate c8754b122d
chore(deps): update golangci/golangci-lint docker tag to v1.57.2 2024-04-07 12:20:52 +02:00
kolaente 4d78ae7fa8
chore(dev): move nix flake to top level, add api tooling
continuous-integration/drone/push Build is passing Details
2024-04-07 12:16:13 +02:00
kolaente c1d06c5e5a
fix(projects): do not return parent project id of parents where the user does not have access
continuous-integration/drone/push Build is failing Details
This caused the frontend to not show such projects, throwing errors in the process and sometimes made it hang.
2024-04-07 12:10:20 +02:00
kolaente f1c3ce5eeb
fix(projects): allow arbitrary nesting of new projects 2024-04-07 12:00:39 +02:00
kolaente 2f6b395334
feat(kanban): set task position to 0 (top) when it is moved into the done bucket automatically after marking it done
continuous-integration/drone/push Build is passing Details
2024-04-06 14:35:05 +02:00
kolaente 1cd5dd2b2f
fix: lint
continuous-integration/drone/push Build is passing Details
2024-04-06 14:12:08 +02:00
kolaente 521300613f
fix: update task in typesense when adding a label or assignee to them
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/typesense-only-works-if-i-re-index/2212
2024-04-06 14:04:04 +02:00
kolaente 037022e857
fix: do not try to fetch nonexistant bucket
continuous-integration/drone/push Build is failing Details
2024-04-06 13:55:11 +02:00
kolaente ec1ff80791
fix(kanban): save done and default bucket on the view and not on the project
The frontend was still trying to update the two in the project which won't work since they are now saved at the view level, not the project.
2024-04-06 13:32:54 +02:00
kolaente 7b8fab33a5
fix(kanban): Make sure all saved taskBucket positions are saved with their project view id
continuous-integration/drone/push Build is passing Details
When the tasks were migrated from belonging directly to a bucket to only belonging to a view, I forgot to add the view in that migration, resulting in task buckets where the view was 0. These entries were not deleted when a task was moved between buckets, but the new task bucket relation nevertheless inserted. This resulted in tasks showing up multiple times on the kanban board.

This change adds a new migration which adds the correct project view id (as derived from the bucket) and fixes the old migration as well.

Resolves https://community.vikunja.io/t/no-longer-able-to-properly-move-tasks-between-kanban-columns/2175
2024-04-06 13:04:36 +02:00
kolaente e0417c8bda
docs: add Korganizer to supported caldav clients
continuous-integration/drone/push Build is passing Details
2024-04-06 12:15:08 +02:00
kolaente 6fbd24d5f6
fix(filter): move spaces out of button to after the matched filter value to prevent removal of spaces
continuous-integration/drone/push Build is failing Details
2024-04-06 12:08:58 +02:00
kolaente e534a6a5bf
fix(modal): do not set p in modal card as flex
This fixes a bug where the description of a project or filter would be aligned right.
2024-04-06 12:08:58 +02:00
kolaente bf85cb0505
fix(filters): always show filter values in a readable color 2024-04-06 12:08:57 +02:00
kolaente 20e2314128
fix(filters): enclose values with a slash in them as strings so that date math values work
Previously, in a filter like "due_date = now/d", the / was parsed as the beginning of a comment, but as it did not contain the full value, this is an invalid comment, resulting in an error message.

Resolves https://community.vikunja.io/t/filter-setting-s/1791/12
2024-04-06 12:08:57 +02:00
kolaente 1ebb551864
fix(filters): make sure the same filter attribute is transformed in all instances
Resolves https://community.vikunja.io/t/filter-setting-s/1791/13
2024-04-06 12:08:57 +02:00
renovate 30c1a46ed4 fix(deps): update src.techknowlogick.com/xgo digest to e01c4fb
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-06 09:07:13 +00:00
kolaente 1910f69392
fix(test): correctly mock localstorage in unit tests
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-06 10:34:41 +02:00
renovate fe4a093825 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is failing Details
2024-04-06 00:07:33 +00:00
renovate 90055d063c chore(deps): update dev-dependencies (#2229)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2229
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-05 20:41:15 +00:00
waza-ari f0d695e789 fix(views): remove default filter from frontend, apply by default to new list views instead (#2240)
continuous-integration/drone/push Build is passing Details
Fixes #2234

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2240
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-04-02 13:49:38 +00:00
kolaente 95276ceebe
fix(reactions): do not enable reaction picker when the current user does not have write access
continuous-integration/drone/push Build is passing Details
2024-04-02 14:48:13 +02:00
kolaente 1558921f42
fix(test): use correct selector in Cypress test
continuous-integration/drone/push Build is failing Details
2024-04-02 14:31:15 +02:00
kolaente bf5088e546
fix(sharing): show user display name and avatar when displaying search results
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/autogenerated-username-using-google-openid/2183/12
2024-04-02 14:29:22 +02:00
kolaente 6f366d4907
feat(views): lint
continuous-integration/drone/push Build is failing Details
2024-04-02 14:04:17 +02:00
kolaente d7554d9e70
feat(views): hide view switcher when there is only one view 2024-04-02 14:02:59 +02:00
kolaente 8a72fe26f8
fix(views): refactor filter button slot in wrapper
Before this change, the filter button on the top right was positioned using absolute positioning and plenty of tricks, which were brittle and not really maintainable. Now, the buttons are positioned using flexbox, which should make this a lot more maintainable.
2024-04-02 14:02:31 +02:00
kolaente 13cab62d14
fix(views): transform view filter before and after loading it from the api
continuous-integration/drone/push Build is failing Details
Previously, the actual filter was kept as-is when sending it to the api, essentially creating an invalid filter. This change fixes this, transforming the filter before saving and after loading.

Resolves #2233
2024-04-02 13:20:17 +02:00
kolaente 81de986d8d
fix(gantt): correctly show day in chart 2024-04-02 12:53:14 +02:00
kolaente 915f677c2a
fix(views): correctly pass view id to wrapper when gantt view is active 2024-04-02 12:50:10 +02:00
kolaente 8a6e3d5bd7
fix(views): use correct assertion in test
continuous-integration/drone/push Build is passing Details
2024-04-02 12:42:07 +02:00
kolaente 81fe8391e4
fix(project): load full project after creating a project
continuous-integration/drone/push Build is failing Details
When a new project was created, it contained all details already. This led to duplicated views and overridden attributes in the response.

Resolves #2242
2024-03-29 19:28:17 +01:00
kolaente 89e37b88d9
fix(views): update all fields when updating a view
continuous-integration/drone/push Build is failing Details
Resolves #2241
2024-03-29 18:19:16 +01:00
kolaente cc6801c5b1
fix(filter): make sure highlight works for doneAt attribute
continuous-integration/drone/push Build is failing Details
2024-03-29 18:09:02 +01:00
kolaente 767b058915
fix(filter): add white background to filter input 2024-03-29 18:07:37 +01:00
kolaente 2c0d3f2885
fix(views): add bottom spacing 2024-03-29 18:05:30 +01:00
renovate fa170b9397 fix(deps): update sentry-javascript monorepo to v7.109.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-28 22:06:03 +00:00
kolaente a5fd6f834a
chore(deps): sign drone config
continuous-integration/drone/push Build is passing Details
2024-03-28 23:00:24 +01:00
renovate 8984e0e9f0 fix(deps): update module github.com/go-sql-driver/mysql to v1.8.1
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details
2024-03-28 21:06:31 +00:00
renovate 176c41dc40 fix(deps): update dependency express to v4.19.2
continuous-integration/drone/push Build is passing Details
2024-03-28 20:57:07 +00:00
waza-ari c4d3d99cd4 fix: pick first available view if currently configured view got deleted (#2235)
continuous-integration/drone/push Build is failing Details
Resolves #2232

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2235
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-28 20:55:52 +00:00
renovate 30b4ed6b23 fix(deps): update dependency dompurify to v3.0.11
continuous-integration/drone/push Build is failing Details
2024-03-28 20:53:00 +00:00
renovate aed92d1cd2 fix(deps): update sentry-javascript monorepo to v7.108.0
continuous-integration/drone/push Build is failing Details
2024-03-28 20:49:17 +00:00
53 changed files with 1258 additions and 1232 deletions

View File

@ -461,7 +461,6 @@ steps:
- cp -r dist-test dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
@ -1401,6 +1400,6 @@ steps:
- failure
---
kind: signature
hmac: b34dc8d5220db311afb5f214eb3395188be38dcf677bae13bfea5d78ca6df450
hmac: ad1d7014fb230dd4ced032ea8995b7a7dc18fcc7cf0805ee38dcbcd6413325af
...

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ vendor/
os-packages/
mage_output_file.go
mage-static
.direnv/

View File

@ -51,11 +51,11 @@
}
},
"devDependencies": {
"electron": "29.1.4",
"electron": "29.2.0",
"electron-builder": "24.13.3"
},
"dependencies": {
"connect-history-api-fallback": "2.0.0",
"express": "4.19.0"
"express": "4.19.2"
}
}

View File

@ -769,10 +769,10 @@ electron-publish@24.13.1:
lazy-val "^1.0.5"
mime "^2.5.2"
electron@29.1.4:
version "29.1.4"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.4.tgz#6c47467ba50be5dd60b99b8737f69cd12fc0733f"
integrity sha512-IWXys0SqgmIfrqXusUGQC0gGG7CCqA5vfmNsUMj8dFkAnK3lisKyjSESStWlrsste/OX/AAC5wsVlf23reUNnw==
electron@29.2.0:
version "29.2.0"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.2.0.tgz#98e9d45dcebda124fb0bd1ff20fc509ec692101c"
integrity sha512-ALKrCN52RG4g9prx4DriXSPnY5WoiyRUCNp7zEVQuoiNOpHTNqMMpRidQAHzntV4hajF1LMWHVoBkwqIs1jHhg==
dependencies:
"@electron/get" "^2.0.0"
"@types/node" "^20.9.0"
@ -830,10 +830,10 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
express@4.19.0:
version "4.19.0"
resolved "https://registry.yarnpkg.com/express/-/express-4.19.0.tgz#c9f689a62522f3399132d49eacd9af177d8ccb9e"
integrity sha512-/ERliX0l7UuHEgAy7HU2FRsiz3ScIKNl/iwnoYzHTJC0Sqj3ctWDD3MQ9CbUEfjshvxXImWaeukD0Xo7a2lWLA==
express@4.19.2:
version "4.19.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465"
integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"

View File

@ -72,6 +72,7 @@ Vikunja **currently does not** support these properties:
* [Evolution](https://wiki.gnome.org/Apps/Evolution/)
* [OpenTasks](https://opentasks.app/) & [DAVx⁵](https://www.davx5.com/)
* [Tasks (Android)](https://tasks.org/)
* [Korganizer](https://apps.kde.org/korganizer/)
### Not working

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1701336116,
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=",
"lastModified": 1712449641,
"narHash": "sha256-U9DDWMexN6o5Td2DznEgguh8TRIUnIl9levmit43GcI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee",
"rev": "600b15aea1b36eeb43833a50b0e96579147099ff",
"type": "github"
},
"original": {

18
flake.nix Normal file
View File

@ -0,0 +1,18 @@
{
description = "Vikunja dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = with pkgs; [
# General tools
git-cliff
# Frontend tools
nodePackages.pnpm cypress
# API tools
go golangci-lint mage
];
};
};
}

1
frontend/.gitignore vendored
View File

@ -13,7 +13,6 @@ node_modules
/dist*
coverage
*.zip
.direnv/
# Test files
cypress/screenshots

View File

@ -21,7 +21,7 @@ describe('Project View Table', () => {
TaskFactory.create(1)
cy.visit('/projects/1/3')
cy.get('.project-table .filter-container .items .button')
cy.get('.project-table .filter-container .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')

View File

@ -1,10 +0,0 @@
{
description = "Vikunja frontend dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress pkgs.git-cliff ]; };
};
}

View File

@ -58,8 +58,8 @@
"@infectoone/vue-ganttastic": "2.3.1",
"@intlify/unplugin-vue-i18n": "3.0.1",
"@kyvg/vue3-notification": "3.2.1",
"@sentry/tracing": "7.107.0",
"@sentry/vue": "7.107.0",
"@sentry/tracing": "7.109.0",
"@sentry/vue": "7.109.0",
"@tiptap/core": "2.2.4",
"@tiptap/extension-blockquote": "2.2.4",
"@tiptap/extension-bold": "2.2.4",
@ -103,7 +103,7 @@
"camel-case": "4.1.2",
"date-fns": "3.6.0",
"dayjs": "1.11.10",
"dompurify": "3.0.10",
"dompurify": "3.0.11",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@ -132,54 +132,54 @@
"@cypress/vite-dev-server": "5.0.7",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.4.1",
"@histoire/plugin-screenshot": "0.17.14",
"@histoire/plugin-vue": "0.17.14",
"@rushstack/eslint-patch": "1.8.0",
"@tsconfig/node18": "18.2.2",
"@histoire/plugin-screenshot": "0.17.15",
"@histoire/plugin-vue": "0.17.15",
"@rushstack/eslint-patch": "1.10.1",
"@tsconfig/node18": "18.2.4",
"@types/codemirror": "5.60.15",
"@types/dompurify": "3.0.5",
"@types/flexsearch": "0.7.6",
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.11.30",
"@types/node": "20.12.5",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"@typescript-eslint/eslint-plugin": "7.5.0",
"@typescript-eslint/parser": "7.5.0",
"@vitejs/plugin-legacy": "5.3.2",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/test-utils": "2.4.5",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.18",
"autoprefixer": "10.4.19",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001599",
"css-has-pseudo": "6.0.2",
"caniuse-lite": "1.0.30001606",
"css-has-pseudo": "6.0.3",
"csstype": "3.1.3",
"cypress": "13.7.0",
"cypress": "13.7.2",
"esbuild": "0.20.2",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.23.0",
"happy-dom": "14.0.0",
"histoire": "0.17.14",
"postcss": "8.4.37",
"eslint-plugin-vue": "9.24.0",
"happy-dom": "14.6.1",
"histoire": "0.17.15",
"postcss": "8.4.38",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.5.2",
"rollup": "4.13.0",
"postcss-preset-env": "9.5.4",
"rollup": "4.14.0",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.72.0",
"sass": "1.74.1",
"start-server-and-test": "2.0.3",
"typescript": "5.4.2",
"vite": "5.1.6",
"typescript": "5.4.4",
"vite": "5.2.8",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.19.5",
"vite-plugin-pwa": "0.19.8",
"vite-plugin-sentry": "1.4.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.4.0",
"vue-tsc": "2.0.6",
"vue-tsc": "2.0.10",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
},

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,6 @@
:project="project"
:is-loading="projectUpdating[project.id]"
:can-collapse="canCollapse"
:level="level"
:data-project-id="project.id"
/>
</template>
@ -49,7 +48,6 @@ const props = defineProps<{
modelValue?: IProject[],
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits<{
(e: 'update:modelValue', projects: IProject[]): void

View File

@ -58,7 +58,6 @@
<ProjectSettingsDropdown
class="menu-list-dropdown"
:project="project"
:level="level"
>
<template #trigger="{toggleOpen}">
<BaseButton
@ -78,7 +77,6 @@
:model-value="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
@ -101,12 +99,10 @@ const {
project,
isLoading,
canCollapse,
level = 0,
} = defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>()
const projectStore = useProjectStore()

View File

@ -16,9 +16,11 @@ import {useColorScheme} from '@/composables/useColorScheme'
const {
entityKind,
entityId,
disabled = false,
} = defineProps<{
entityKind: ReactionKind,
entityId: number,
disabled?: boolean,
}>()
const authStore = useAuthStore()
@ -143,11 +145,13 @@ async function toggleReaction(value: string) {
v-tooltip="getReactionTooltip(users, value)"
class="reaction-button"
:class="{'current-user-has-reacted': hasCurrentUserReactedWithEmoji(value)}"
:disabled
@click="toggleReaction(value)"
>
{{ value }} {{ users.length }}
</BaseButton>
<BaseButton
v-if="!disabled"
ref="emojiPickerButtonRef"
v-tooltip="$t('reaction.add')"
class="reaction-button"

View File

@ -13,7 +13,7 @@
}"
>
<template v-if="icon">
<icon
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
@ -22,7 +22,7 @@
v-else
class="icon is-small"
>
<icon
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
@ -34,20 +34,20 @@
<script lang="ts">
const BUTTON_TYPES_MAP = {
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
} as const
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
export default { name: 'XButton' }
export default {name: 'XButton'}
</script>
<script setup lang="ts">
import {computed, useSlots} from 'vue'
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
// extending the props of the BaseButton
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
@ -76,37 +76,38 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
<style lang="scss" scoped>
.button {
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
line-height: 1;
&:hover {
box-shadow: var(--shadow-md);
}
&:hover {
box-shadow: var(--shadow-md);
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
}
.is-small {
@ -114,6 +115,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
}
.underline-none {
text-decoration: none !important;
text-decoration: none !important;
}
</style>

View File

@ -188,12 +188,6 @@ $modal-width: 1024px;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}

View File

@ -7,8 +7,14 @@
{{ getProjectTitle(currentProject) }}
</h1>
<div class="switch-view-container d-print-none">
<div class="switch-view">
<div
class="switch-view-container d-print-none"
:class="{'is-justify-content-flex-end': views.length === 1}"
>
<div
v-if="views.length > 1"
class="switch-view"
>
<BaseButton
v-for="v in views"
:key="v.id"
@ -149,8 +155,14 @@ function getViewTitle(view: IProjectView) {
<style lang="scss" scoped>
.switch-view-container {
min-height: $switch-view-height;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
@ -162,8 +174,6 @@ function getViewTitle(view: IProjectView) {
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}

View File

@ -91,7 +91,15 @@ const highlightedFilterQuery = computed(() => {
value = ''
}
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>`
let endPadding = ''
if(value.endsWith(' ')) {
const fullLength = value.length
value = value.trimEnd()
const numberOfRemovedSpaces = fullLength - value.length
endPadding = endPadding.padEnd(numberOfRemovedSpaces, ' ')
}
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>${endPadding}`
})
})
ASSIGNEE_FIELDS
@ -317,6 +325,11 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
<style lang="scss">
.filter-input-highlight {
&, button.filter-query__date_value {
color: var(--card-color);
}
span {
&.filter-query__field {
color: var(--code-literal);
@ -379,6 +392,7 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
}
.filter-input-highlight {
background: var(--white);
height: 2.5em;
line-height: 1.5;
padding: .5em .75em;

View File

@ -96,7 +96,6 @@
{{ $t('project.webhooks.title') }}
</DropdownItem>
<DropdownItem
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
@ -135,9 +134,6 @@ const props = defineProps({
type: Object as PropType<IProject>,
required: true,
},
level: {
type: Number,
},
})
const projectStore = useProjectStore()

View File

@ -1,26 +1,10 @@
<!-- 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/>. -->
<template>
<ProjectWrapper
class="project-gantt"
:project-id="filters.projectId"
:view
:view-id
>
<template #header>
<template #default>
<card :has-content="false">
<div class="gantt-options">
<div class="field">
@ -61,9 +45,7 @@
</Fancycheckbox>
</div>
</card>
</template>
<template #default>
<div class="gantt-chart-container">
<card
:has-content="false"
@ -95,7 +77,7 @@ import {useI18n} from 'vue-i18n'
import type {RouteLocationNormalized} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'

View File

@ -41,7 +41,7 @@
@click="() => unCollapseBucket(bucket)"
>
<span
v-if="project?.doneBucketId === bucket.id"
v-if="view?.doneBucketId === bucket.id"
v-tooltip="$t('project.kanban.doneBucketHint')"
class="icon is-small has-text-success mr-2"
>
@ -109,7 +109,7 @@
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
:icon-class="{'has-text-success': bucket.id === project?.doneBucketId}"
:icon-class="{'has-text-success': bucket.id === view?.doneBucketId}"
icon="check-double"
@click.stop="toggleDoneBucket(bucket)"
>
@ -117,7 +117,7 @@
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === project.defaultBucketId}"
:icon-class="{'has-text-primary': bucket.id === view?.defaultBucketId}"
icon="th"
@click.stop="toggleDefaultBucket(bucket)"
>
@ -304,6 +304,8 @@ import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import {i18n} from '@/i18n'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
const {
projectId,
@ -393,6 +395,8 @@ const project = computed(() => projectId ? projectStore.projects[projectId] : nu
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const view = computed<IProjectView | null>(() => project.value?.views.find(v => v.id === viewId) || null)
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
watch(
@ -701,26 +705,46 @@ function dragstart(bucket: IBucket) {
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = project.value.defaultBucketId === bucket.id
const defaultBucketId = view.value?.defaultBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
defaultBucketId,
})
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function toggleDoneBucket(bucket: IBucket) {
const doneBucketId = project.value?.doneBucketId === bucket.id
const doneBucketId = view.value?.doneBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
doneBucketId,
})
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.doneBucketSavedSuccess')})
}

View File

@ -6,69 +6,68 @@
>
<template #header>
<div class="filter-container">
<div class="items">
<Popup>
<template #trigger="{toggle}">
<x-button
icon="th"
variant="secondary"
@click.prevent.stop="toggle()"
>
{{ $t('project.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card
class="columns-filter"
:class="{'is-open': isOpen}"
>
<Fancycheckbox v-model="activeColumns.index">
#
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</Fancycheckbox>
</card>
</template>
</Popup>
<FilterPopup v-model="params" />
</div>
<Popup>
<template #trigger="{toggle}">
<x-button
icon="th"
variant="secondary"
class="mr-2"
@click.prevent.stop="toggle()"
>
{{ $t('project.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card
class="columns-filter"
:class="{'is-open': isOpen}"
>
<Fancycheckbox v-model="activeColumns.index">
#
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</Fancycheckbox>
</card>
</template>
</Popup>
<FilterPopup v-model="params" />
</div>
</template>
@ -397,4 +396,8 @@ const taskDetailRoutes = computed(() => Object.fromEntries(
border: none;
box-shadow: none;
}
.filter-container :deep(.popup) {
top: 7rem;
}
</style>

View File

@ -2,12 +2,65 @@
import type {IProjectView} from '@/modelTypes/IProjectView'
import XButton from '@/components/input/button.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref} from 'vue'
import {ref, watch} from 'vue'
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
const {
modelValue,
} = defineProps<{
modelValue: IProjectView,
}>()
const emit = defineEmits(['update:modelValue'])
const view = ref<IProjectView>()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
watch(
() => modelValue,
newValue => {
const transformed = {
...newValue,
filter: transformFilterStringFromApi(
newValue.filter,
labelId => labelStore.getLabelById(labelId)?.title,
projectId => projectStore.projects[projectId]?.title || null,
),
}
if (JSON.stringify(view.value) !== JSON.stringify(transformed)) {
view.value = transformed
}
},
{immediate: true, deep: true},
)
watch(
() => view.value,
newView => {
emit('update:modelValue', {
...newView,
filter: transformFilterStringForApi(
newView.filter,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
),
})
},
{deep: true},
)
const model = defineModel<IProjectView>()
const titleValid = ref(true)
function validateTitle() {
titleValid.value = model.value.title !== ''
titleValid.value = view.value?.title !== ''
}
</script>
@ -23,14 +76,14 @@ function validateTitle() {
<div class="control">
<input
id="title"
v-model="model.title"
v-model="view.title"
v-focus
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
@blur="validateTitle"
>
</div>
<p
<p
v-if="!titleValid"
class="help is-danger"
>
@ -49,7 +102,7 @@ function validateTitle() {
<div class="select">
<select
id="kind"
v-model="model.viewKind"
v-model="view.viewKind"
>
<option value="list">
{{ $t('project.list.title') }}
@ -69,12 +122,12 @@ function validateTitle() {
</div>
<FilterInput
v-model="model.filter"
v-model="view.filter"
:input-label="$t('project.views.filter')"
/>
<div
v-if="model.viewKind === 'kanban'"
v-if="view.viewKind === 'kanban'"
class="field"
>
<label
@ -87,7 +140,7 @@ function validateTitle() {
<div class="select">
<select
id="configMode"
v-model="model.bucketConfigurationMode"
v-model="view.bucketConfigurationMode"
>
<option value="manual">
{{ $t('project.views.bucketConfigManual') }}
@ -101,7 +154,7 @@ function validateTitle() {
</div>
<div
v-if="model.viewKind === 'kanban' && model.bucketConfigurationMode === 'filter'"
v-if="view.viewKind === 'kanban' && view.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">
@ -109,13 +162,13 @@ function validateTitle() {
</label>
<div class="control">
<div
v-for="(b, index) in model.bucketConfiguration"
v-for="(b, index) in view.bucketConfiguration"
:key="'bucket_'+index"
class="filter-bucket"
>
<button
class="is-danger"
@click.prevent="() => model.bucketConfiguration.splice(index, 1)"
@click.prevent="() => view.bucketConfiguration.splice(index, 1)"
>
<icon icon="trash-alt" />
</button>
@ -130,7 +183,7 @@ function validateTitle() {
<div class="control">
<input
:id="'bucket_'+index+'_title'"
v-model="model.bucketConfiguration[index].title"
v-model="view.bucketConfiguration[index].title"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
@ -138,7 +191,7 @@ function validateTitle() {
</div>
<FilterInput
v-model="model.bucketConfiguration[index].filter"
v-model="view.bucketConfiguration[index].filter"
:input-label="$t('project.views.filter')"
/>
</div>
@ -147,7 +200,7 @@ function validateTitle() {
<XButton
variant="secondary"
icon="plus"
@click="() => model.bucketConfiguration.push({title: '', filter: ''})"
@click="() => view.bucketConfiguration.push({title: '', filter: ''})"
>
{{ $t('project.kanban.addBucket') }}
</XButton>

View File

@ -16,7 +16,22 @@
:search-results="found"
:label="searchLabel"
@search="find"
/>
>
<template #searchResult="{option: result}">
<User
v-if="shareType === 'user'"
:avatar-size="24"
:show-username="true"
:user="result"
/>
<span
v-else
class="search-result"
>
{{ result.name }}
</span>
</template>
</Multiselect>
</p>
<p class="control">
<x-button @click="add()">
@ -173,6 +188,7 @@ import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import User from '@/components/misc/user.vue'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?

View File

@ -21,12 +21,12 @@
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{value, date}">
<template #timeunit="{date}">
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ value }}</span>
<span>{{ date.getDate() }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>

View File

@ -102,6 +102,7 @@
class="mt-2"
entity-kind="comments"
:entity-id="c.id"
:disabled="!canWrite"
/>
</div>
</div>

View File

@ -107,7 +107,7 @@ describe('Filter Transformation', () => {
expect(transformed).toBe('project = 1')
})
it('should resolve project and labels independently', () => {
const transformed = transformFilterStringForApi(
'project = lorem && labels = ipsum',
@ -117,6 +117,16 @@ describe('Filter Transformation', () => {
expect(transformed).toBe('project = 1 && labels = 2')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringForApi(
'dueDate = now/d || dueDate > now/w+1w',
nullTitleToIdResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('due_date = now/d || due_date > now/w+1w')
})
})
describe('To API', () => {
@ -198,5 +208,15 @@ describe('Filter Transformation', () => {
expect(transformed).toBe('project in lorem, ipsum')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringFromApi(
'due_date = now/d || due_date > now/w+1w',
nullIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('dueDate = now/d || dueDate > now/w+1w')
})
})
})

View File

@ -27,13 +27,13 @@ export const AUTOCOMPLETE_FIELDS = [
]
export const AVAILABLE_FILTER_FIELDS = [
'done',
'priority',
'percentDone',
...DATE_FIELDS,
...ASSIGNEE_FIELDS,
...LABEL_FIELDS,
...PROJECT_FIELDS,
'done',
'priority',
'percentDone',
]
export const FILTER_OPERATORS = [
@ -127,7 +127,7 @@ export function transformFilterStringForApi(
// Transform all attributes to snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replace(f, snakeCase(f))
filter = filter.replaceAll(f, snakeCase(f))
})
return filter
@ -145,7 +145,7 @@ export function transformFilterStringFromApi(
// Transform all attributes from snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replace(snakeCase(f), f)
filter = filter.replaceAll(snakeCase(f), f)
})
// Transform labels to their titles

View File

@ -20,8 +20,6 @@ export interface IProject extends IAbstract {
position: number
backgroundBlurHash: string
parentProjectId: number
doneBucketId: number
defaultBucketId: number
views: IProjectView[]
created: Date

View File

@ -24,8 +24,6 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
position = 0
backgroundBlurHash = ''
parentProjectId = 0
doneBucketId = 0
defaultBucketId = 0
views = []
created: Date = null

View File

@ -2,14 +2,14 @@ import {test, expect, vi} from 'vitest'
import {getHistory, removeProjectFromHistory, saveProjectToHistory} from './projectHistory'
test('return an empty history when none was saved', () => {
Storage.prototype.getItem = vi.fn(() => null)
vi.spyOn(localStorage, 'getItem').mockImplementation(() => null)
const h = getHistory()
expect(h).toStrictEqual([])
})
test('return a saved history', () => {
const saved = [{id: 1}, {id: 2}]
Storage.prototype.getItem = vi.fn(() => JSON.stringify(saved))
vi.spyOn(localStorage, 'getItem').mockImplementation(() => JSON.stringify(saved))
const h = getHistory()
expect(h).toStrictEqual(saved)
@ -17,8 +17,8 @@ test('return a saved history', () => {
test('store project in history', () => {
let saved = {}
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key, projects) => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => null)
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, projects: string) => {
saved = projects
})
@ -28,8 +28,8 @@ test('store project in history', () => {
test('store only the last 5 projects in history', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => saved)
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, projects: string) => {
saved = projects
})
@ -44,8 +44,8 @@ test('store only the last 5 projects in history', () => {
test('don\'t store the same project twice', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => saved)
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, projects: string) => {
saved = projects
})
@ -56,8 +56,8 @@ test('don\'t store the same project twice', () => {
test('move a project to the beginning when storing it multiple times', () => {
let saved: string | null = null
Storage.prototype.getItem = vi.fn(() => saved)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => saved)
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, projects: string) => {
saved = projects
})
@ -69,11 +69,11 @@ test('move a project to the beginning when storing it multiple times', () => {
test('remove project from history', () => {
let saved: string | null = '[{"id": 1}]'
Storage.prototype.getItem = vi.fn(() => null)
Storage.prototype.setItem = vi.fn((key: string, projects: string) => {
vi.spyOn(localStorage, 'getItem').mockImplementation(() => saved)
vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, projects: string) => {
saved = projects
})
Storage.prototype.removeItem = vi.fn((key: string) => {
vi.spyOn(localStorage, 'removeItem').mockImplementation((key: string) => {
saved = null
})

View File

@ -18,7 +18,7 @@ export function getDefaultTaskFilterParams(): TaskFilterParams {
return {
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter: 'done = false',
filter: '',
filter_include_nulls: false,
filter_timezone: '',
s: '',

View File

@ -1,5 +1,4 @@
@import "tooltip";
@import "labels";
@import "project";
@import "task";
@import "tasks";

View File

@ -1,77 +0,0 @@
// FIXME: should be a component <FilterContainer>
// used in
// - Kanban.vue
// - Project.vue
// - Table.vue
$filter-container-top-default: -59px;
$filter-container-top-link-share-gantt: -133px;
$filter-container-top-link-share-list: -47px;
.filter-container {
text-align: right;
width: 100%;
min-width: 400px;
max-width: 180px;
position: absolute;
right: 1.5rem;
margin-top: $filter-container-top-default;
z-index: 4;
display: flex;
justify-content: flex-end;
.button:not(:last-of-type) {
margin-right: .5rem;
}
.button {
height: $switch-view-height;
}
.card {
text-align: left;
}
@media screen and (max-width: $tablet) {
position: static;
margin: 0 0 1rem 0 !important;
max-width: 100%;
min-width: auto;
.items {
justify-content: center;
}
.search {
width: 100%;
.control:first-child {
width: 100%;
}
}
}
}
.link-share-container .gantt-chart-container .filter-container,
.gantt-chart-container .filter-container {
right: 0;
margin-top: calc(#{$filter-container-top-link-share-gantt - 2} - 7rem);
}
.link-share-container .gantt-chart-container .filter-container {
margin-top: calc(#{$filter-container-top-link-share-gantt} - 5rem);
}
.link-share-container .list-view .filter-container {
margin-top: $filter-container-top-link-share-list - 10px;
}
.link-share-container.project\.table-view,
.link-share-container.project\.list-view {
.filter-container {
right: 9rem;
margin-top: $filter-container-top-default;
}
}

View File

@ -26,7 +26,7 @@ const currentView = computed(() => {
})
function redirectToFirstViewIfNecessary() {
if (viewId === 0) {
if (viewId === 0 || !projectStore.projects[projectId]?.views.find(v => v.id === viewId)) {
// Ideally, we would do that in the router redirect, but the projects (and therefore, the views)
// are not always loaded then.
const firstViewId = projectStore.projects[projectId]?.views[0].id

View File

@ -89,7 +89,7 @@ async function saveView() {
v-model="newView"
class="mb-4"
/>
<div class="is-flex is-justify-content-end">
<div class="is-flex is-justify-content-end mb-4">
<XButton
:loading="projectViewService.loading"
@click="createView"

View File

@ -328,6 +328,7 @@
entity-kind="tasks"
:entity-id="task.id"
class="details"
:disabled="!canWrite"
/>
<!-- Attachments -->

4
go.mod
View File

@ -33,7 +33,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3
github.com/ganigeorgiev/fexpr v0.4.0
github.com/getsentry/sentry-go v0.27.0
github.com/go-sql-driver/mysql v1.8.0
github.com/go-sql-driver/mysql v1.8.1
github.com/go-testfixtures/testfixtures/v3 v3.10.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt/v5 v5.2.1
@ -77,7 +77,7 @@ require (
gopkg.in/d4l3k/messagediff.v1 v1.2.1
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/xurls/v2 v2.5.0
src.techknowlogick.com/xgo v1.7.1-0.20240305180710-770b8eae9cec
src.techknowlogick.com/xgo v1.7.1-0.20240403232151-e01c4fbef884
src.techknowlogick.com/xormigrate v1.7.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9

4
go.sum
View File

@ -158,6 +158,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-testfixtures/testfixtures/v3 v3.10.0 h1:BrBwN7AuC+74g5qtk9D59TLGOaEa8Bw1WmIsf+SyzWc=
@ -786,6 +788,8 @@ sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
src.techknowlogick.com/xgo v1.7.1-0.20240305180710-770b8eae9cec h1:ICDp83UjJvLcOFWHAxr7vmziKIHJkE4jsIF1mbT9Bwk=
src.techknowlogick.com/xgo v1.7.1-0.20240305180710-770b8eae9cec/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xgo v1.7.1-0.20240403232151-e01c4fbef884 h1:Ttvt8FCpUXfC8r3+LgSPrBUIr/JkHmYQtvmOwEET8qE=
src.techknowlogick.com/xgo v1.7.1-0.20240403232151-e01c4fbef884/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
src.techknowlogick.com/xormigrate v1.7.1 h1:RKGLLUAqJ+zO8iZ7eOc7oLH7f0cs2gfXSZSvBRBHnlY=
src.techknowlogick.com/xormigrate v1.7.1/go.mod h1:YGNBdj8prENlySwIKmfoEXp7ILGjAltyKFXD0qLgD7U=
xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=

View File

@ -78,8 +78,9 @@ func init() {
if view.ViewKind == 3 { // Kanban view
pos := taskBuckets20240315110428{
TaskID: task.ID,
BucketID: task.BucketID,
TaskID: task.ID,
BucketID: task.BucketID,
ProjectViewID: view.ID,
}
_, err = tx.Insert(pos)

View File

@ -0,0 +1,51 @@
// 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 (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type projectView20240329170952 struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
Filter string `xorm:"text null default null" query:"filter" json:"filter"`
ViewKind int `xorm:"not null" json:"view_kind"`
}
func (projectView20240329170952) TableName() string {
return "project_views"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20240329170952",
Description: "Update default filter for list views to hide completed tasks",
Migrate: func(tx *xorm.Engine) error {
// Update the filter for all list views to hide completed tasks unless the filter is already set
_, err := tx.Where("view_kind = ? AND filter = ?", 0, "").Cols("filter").Update(&projectView20240329170952{Filter: "done = false"})
if err != nil {
return err
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,89 @@
// 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 (
"code.vikunja.io/api/pkg/log"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type taskBucket20240406125227 struct {
BucketID int64 `xorm:"bigint not null index"`
TaskID int64 `xorm:"bigint not null index"`
ProjectViewID int64 `xorm:"bigint not null index"`
}
func (taskBucket20240406125227) TableName() string {
return "task_buckets"
}
type bucket20240406125227 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"bucket"`
ProjectViewID int64 `xorm:"bigint not null" json:"project_view_id" param:"view"`
}
func (bucket20240406125227) TableName() string {
return "buckets"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20240406125227",
Description: "Add correct project_view_id to task_buckets",
Migrate: func(tx *xorm.Engine) error {
buckets := make(map[int64]*bucket20240406125227)
err := tx.Find(&buckets)
if err != nil {
return err
}
tbs := []*taskBucket20240406125227{}
err = tx.Where("project_view_id = 0").Find(&tbs)
if err != nil {
return err
}
if len(tbs) == 0 {
return nil
}
for _, tb := range tbs {
bucket, exists := buckets[tb.BucketID]
if !exists {
log.Debugf("Bucket %d does not exist but has task_buckets relation", tb.BucketID)
continue
}
tb.ProjectViewID = bucket.ProjectViewID
_, err = tx.
Where("task_id = ? AND bucket_id = ?", tb.TaskID, tb.BucketID).
Cols("project_view_id").
Update(tb)
if err != nil {
return err
}
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -384,27 +384,27 @@ func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) {
}
// Get the default bucket
p, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID)
pv, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID)
if err != nil {
return
}
var updateProject bool
if b.ID == p.DefaultBucketID {
p.DefaultBucketID = 0
updateProject = true
var updateProjectView bool
if b.ID == pv.DefaultBucketID {
pv.DefaultBucketID = 0
updateProjectView = true
}
if b.ID == p.DoneBucketID {
p.DoneBucketID = 0
updateProject = true
if b.ID == pv.DoneBucketID {
pv.DoneBucketID = 0
updateProjectView = true
}
if updateProject {
err = p.Update(s, a)
if updateProjectView {
err = pv.Update(s, a)
if err != nil {
return
}
}
defaultBucketID, err := getDefaultBucketID(s, p)
defaultBucketID, err := getDefaultBucketID(s, pv)
if err != nil {
return err
}

View File

@ -216,10 +216,10 @@ func TestBucket_Delete(t *testing.T) {
err := b.Delete(s, u)
require.NoError(t, err)
db.AssertMissing(t, "project_views", map[string]interface{}{
db.AssertExists(t, "project_views", map[string]interface{}{
"id": b.ProjectViewID,
"done_bucket_id": 0,
})
}, false)
})
}

View File

@ -77,7 +77,7 @@ func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxRigh
builder.
Select("id").
From("tasks").
Where(builder.In("project_id", getUserProjectsStatement(nil, u.ID, "", false).Select("l.id"))),
Where(builder.In("project_id", getUserProjectsStatement(u.ID, "", false).Select("l.id"))),
)
ll := &LabelTask{}

View File

@ -22,7 +22,7 @@ import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
@ -46,7 +46,7 @@ type LabelTask struct {
}
// TableName makes a pretty table name
func (LabelTask) TableName() string {
func (*LabelTask) TableName() string {
return "label_tasks"
}
@ -84,7 +84,7 @@ func (lt *LabelTask) Delete(s *xorm.Session, _ web.Auth) (err error) {
// @Failure 404 {object} web.HTTPError "The label does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{task}/labels [put]
func (lt *LabelTask) Create(s *xorm.Session, _ web.Auth) (err error) {
func (lt *LabelTask) Create(s *xorm.Session, auth web.Auth) (err error) {
// Check if the label is already added
exists, err := s.Exist(&LabelTask{LabelID: lt.LabelID, TaskID: lt.TaskID})
if err != nil {
@ -100,6 +100,20 @@ func (lt *LabelTask) Create(s *xorm.Session, _ web.Auth) (err error) {
return err
}
t, err := GetTaskByIDSimple(s, lt.TaskID)
if err != nil {
return err
}
doer, _ := user.GetFromAuth(auth)
err = events.Dispatch(&TaskUpdatedEvent{
Task: &t,
Doer: doer,
})
if err != nil {
return err
}
err = updateProjectByTaskID(s, lt.TaskID)
return
}

View File

@ -368,7 +368,7 @@ type projectOptions struct {
getArchived bool
}
func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search string, getArchived bool) *builder.Builder {
func getUserProjectsStatement(userID int64, search string, getArchived bool) *builder.Builder {
dialect := db.GetDialect()
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
@ -413,18 +413,13 @@ func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search str
),
)
}
projectCol := "id"
if len(parentProjectIDs) > 0 {
parentCondition = builder.In("l.parent_project_id", parentProjectIDs)
projectCol = "parent_project_id"
}
return builder.Dialect(dialect).
Select("l.*").
From("projects", "l").
Join("LEFT", "team_projects tl", "tl.project_id = l."+projectCol).
Join("LEFT", "team_projects tl", "tl.project_id = l.id").
Join("LEFT", "team_members tm2", "tm2.team_id = tl.team_id").
Join("LEFT", "users_projects ul", "ul.project_id = l."+projectCol).
Join("LEFT", "users_projects ul", "ul.project_id = l.id").
Where(builder.And(
builder.Or(
builder.Eq{"tm2.user_id": userID},
@ -434,7 +429,6 @@ func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search str
filterCond,
getArchivedCond,
parentCondition,
builder.NotIn("l.id", parentProjectIDs),
)).
GroupBy("l.id")
}
@ -442,7 +436,7 @@ func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search str
func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions) (projects []*Project, totalCount int64, err error) {
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
query := getUserProjectsStatement(nil, userID, opts.search, opts.getArchived)
query := getUserProjectsStatement(userID, opts.search, opts.getArchived)
querySQLString, args, err := query.ToSQL()
if err != nil {
@ -459,9 +453,26 @@ UNION ALL
SELECT p.* FROM projects p
INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
columnStr := strings.Join([]string{
"all_projects.id",
"all_projects.title",
"all_projects.description",
"all_projects.identifier",
"all_projects.hex_color",
"all_projects.owner_id",
"CASE WHEN np.id IS NULL THEN 0 ELSE all_projects.parent_project_id END AS parent_project_id",
"all_projects.is_archived",
"all_projects.background_file_id",
"all_projects.background_blur_hash",
"all_projects.position",
"all_projects.created",
"all_projects.updated",
}, ", ")
currentProjects := []*Project{}
err = s.SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
SELECT DISTINCT * FROM all_projects ORDER BY position `+limitSQL, args...).Find(&currentProjects)
SELECT DISTINCT `+columnStr+` FROM all_projects
LEFT JOIN all_projects np on all_projects.parent_project_id = np.id
ORDER BY all_projects.position `+limitSQL, args...).Find(&currentProjects)
if err != nil {
return
}
@ -991,7 +1002,12 @@ func (p *Project) Create(s *xorm.Session, a web.Auth) (err error) {
return
}
return p.ReadOne(s, a)
fullProject, err := GetProjectSimpleByID(s, p.ID)
if err != nil {
return
}
return fullProject.ReadOne(s, a)
}
func (p *Project) isDefaultProject(s *xorm.Session) (is bool, err error) {

View File

@ -331,11 +331,19 @@ func (p *ProjectView) Update(s *xorm.Session, _ web.Auth) (err error) {
return
}
_, err = s.ID(p.ID).Update(p)
if err != nil {
return
}
_, err = s.
ID(p.ID).
Cols(
"title",
"view_kind",
"filter",
"position",
"bucket_configuration_mode",
"bucket_configuration",
"default_bucket_id",
"done_bucket_id",
).
Update(p)
return
}
@ -383,6 +391,7 @@ func CreateDefaultViewsForProject(s *xorm.Session, project *Project, a web.Auth,
Title: "List",
ViewKind: ProjectViewKindList,
Position: 100,
Filter: "done = false",
}
err = createProjectView(s, list, a, createBacklogBucket)
if err != nil {

View File

@ -280,6 +280,13 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, project
if err != nil {
return err
}
err = events.Dispatch(&TaskUpdatedEvent{
Task: t,
Doer: doer,
})
if err != nil {
return err
}
err = updateProjectLastUpdated(s, &Project{ID: t.ProjectID})
return

View File

@ -24,10 +24,9 @@ import (
"strings"
"time"
"github.com/ganigeorgiev/fexpr"
"code.vikunja.io/api/pkg/config"
"github.com/ganigeorgiev/fexpr"
"github.com/iancoleman/strcase"
"github.com/jszwedko/go-datemath"
"xorm.io/xorm/schemas"
@ -155,15 +154,27 @@ func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filte
filter = strings.ReplaceAll(filter, " in ", " ?= ")
// Replaces all occurrences with in with a string so that it passes the filter
pattern := `\?=\s+([^&|']+)`
pattern := `(\?=\s+([^&|']+))|(([<>]?=|[<>])[^&|')]+\/[^&|')]+([&|')]+))`
re := regexp.MustCompile(pattern)
filter = re.ReplaceAllStringFunc(filter, func(match string) string {
value := strings.TrimSpace(strings.TrimPrefix(match, "?="))
comparator := match[:2]
value := strings.TrimSpace(match[2:])
if match[1] == ' ' {
comparator = match[:1]
}
var end string
if value[len(value)-1:] == ")" {
end = ")"
value = value[0 : len(value)-1]
}
value = strings.ReplaceAll(value, "'", `\'`)
enclosedValue := "'" + value + "'"
return "?= " + enclosedValue
return comparator + " " + enclosedValue + end
})
parsedFilter, err := fexpr.Parse(filter)

View File

@ -672,9 +672,21 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *Projec
return
}
// If the task was marked as done and the view has a done bucket, move the task to the done bucket
if task.Done && originalTask != nil &&
(!originalTask.Done || task.ProjectID != originalTask.ProjectID) {
targetBucket.BucketID = view.DoneBucketID
// …and also reset the position so that it shows up at the top
// Note: this might result in an "off-looking" position when there is already a task with position 0.
// This is done by design, because recalculating all positions is really costly and will happen
// later anyway.
_, err = s.
Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).
Cols("position").
Update(&TaskPosition{Position: 0})
if err != nil {
return
}
}
if targetBucket.BucketID == 0 && oldTaskBucket.BucketID != 0 {