Merge branch 'main' into feature/trello-per-organization-migration
continuous-integration/drone/pr Build is passing
Details
continuous-integration/drone/pr Build is passing
Details
This commit is contained in:
commit
879cb38721
|
@ -139,7 +139,7 @@ steps:
|
|||
event: [ push, tag, pull_request ]
|
||||
|
||||
- name: api-lint
|
||||
image: golangci/golangci-lint:v1.56.2
|
||||
image: golangci/golangci-lint:v1.57.2
|
||||
pull: always
|
||||
environment:
|
||||
GOPROXY: 'https://goproxy.kolaente.de'
|
||||
|
@ -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
|
||||
|
@ -717,7 +716,7 @@ steps:
|
|||
|
||||
# Build os packages and push it to our bucket
|
||||
- name: build-os-packages-unstable
|
||||
image: goreleaser/nfpm:v2.35.3
|
||||
image: goreleaser/nfpm:v2.36.1
|
||||
pull: always
|
||||
commands:
|
||||
- apk add git go
|
||||
|
@ -733,7 +732,7 @@ steps:
|
|||
depends_on: [ after-build-compress ]
|
||||
|
||||
- name: build-os-packages-version
|
||||
image: goreleaser/nfpm:v2.35.3
|
||||
image: goreleaser/nfpm:v2.36.1
|
||||
pull: always
|
||||
commands:
|
||||
- apk add git go
|
||||
|
@ -1401,6 +1400,6 @@ steps:
|
|||
- failure
|
||||
---
|
||||
kind: signature
|
||||
hmac: a5d31a6cb5eb6482e72bea619ee391ff2b8118b9865e3896a607b8a7e874a797
|
||||
hmac: 2c9cb0483fb346988188515f6423929f46eefb9e14eb26b0f312a0b694d5fe8c
|
||||
|
||||
...
|
||||
|
|
|
@ -28,3 +28,4 @@ vendor/
|
|||
os-packages/
|
||||
mage_output_file.go
|
||||
mage-static
|
||||
.direnv/
|
||||
|
|
|
@ -18,6 +18,7 @@ linters:
|
|||
- scopelint # Obsolete, using exportloopref instead
|
||||
- durationcheck
|
||||
- goconst
|
||||
- musttag
|
||||
presets:
|
||||
- bugs
|
||||
- unused
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -35,7 +35,7 @@ That means compiling it boils down to these steps:
|
|||
|
||||
1. Make sure [Go](https://golang.org/doc/install) is properly installed on your system. You'll need at least Go `1.21`.
|
||||
2. Make sure [Mage](https://magefile.org) is properly installed on your system.
|
||||
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch index.html`.
|
||||
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch frontend/dist/index.html`.
|
||||
4. Run `mage build` in the source of the main repo. This will build a binary in the root of the repo which will be able to run on your system.
|
||||
|
||||
### Build for different architectures
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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": {
|
|
@ -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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
|
@ -13,7 +13,6 @@ node_modules
|
|||
/dist*
|
||||
coverage
|
||||
*.zip
|
||||
.direnv/
|
||||
|
||||
# Test files
|
||||
cypress/screenshots
|
||||
|
|
|
@ -12,7 +12,7 @@ describe('Project History', () => {
|
|||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||
|
||||
const projects = ProjectFactory.create(6)
|
||||
const projects = ProjectFactory.create(7)
|
||||
ProjectViewFactory.truncate()
|
||||
projects.forEach(p => ProjectViewFactory.create(1, {
|
||||
id: p.id,
|
||||
|
@ -36,6 +36,8 @@ describe('Project History', () => {
|
|||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[6].id}/${projects[6].id}`)
|
||||
cy.wait('@loadProject')
|
||||
|
||||
// cy.visit('/')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
|
@ -52,5 +54,6 @@ describe('Project History', () => {
|
|||
.should('contain', projects[3].title)
|
||||
.should('contain', projects[4].title)
|
||||
.should('contain', projects[5].title)
|
||||
.should('contain', projects[6].title)
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
|
|
|
@ -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 ]; };
|
||||
};
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@8.15.5",
|
||||
"packageManager": "pnpm@8.15.6",
|
||||
"keywords": [
|
||||
"todo",
|
||||
"productivity",
|
||||
|
@ -50,49 +50,49 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "6.5.2",
|
||||
"@fortawesome/free-regular-svg-icons": "6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.5.2",
|
||||
"@fortawesome/vue-fontawesome": "3.0.6",
|
||||
"@github/hotkey": "3.1.0",
|
||||
"@infectoone/vue-ganttastic": "2.3.1",
|
||||
"@infectoone/vue-ganttastic": "2.3.2",
|
||||
"@intlify/unplugin-vue-i18n": "3.0.1",
|
||||
"@kyvg/vue3-notification": "3.2.1",
|
||||
"@sentry/tracing": "7.107.0",
|
||||
"@sentry/vue": "7.107.0",
|
||||
"@tiptap/core": "2.2.4",
|
||||
"@tiptap/extension-blockquote": "2.2.4",
|
||||
"@tiptap/extension-bold": "2.2.4",
|
||||
"@tiptap/extension-bullet-list": "2.2.4",
|
||||
"@tiptap/extension-code": "2.2.4",
|
||||
"@tiptap/extension-code-block-lowlight": "2.2.4",
|
||||
"@tiptap/extension-document": "2.2.4",
|
||||
"@tiptap/extension-dropcursor": "2.2.4",
|
||||
"@tiptap/extension-gapcursor": "2.2.4",
|
||||
"@tiptap/extension-hard-break": "2.2.4",
|
||||
"@tiptap/extension-heading": "2.2.4",
|
||||
"@tiptap/extension-history": "2.2.4",
|
||||
"@tiptap/extension-horizontal-rule": "2.2.4",
|
||||
"@tiptap/extension-image": "2.2.4",
|
||||
"@tiptap/extension-italic": "2.2.4",
|
||||
"@tiptap/extension-link": "2.2.4",
|
||||
"@tiptap/extension-list-item": "2.2.4",
|
||||
"@tiptap/extension-ordered-list": "2.2.4",
|
||||
"@tiptap/extension-paragraph": "2.2.4",
|
||||
"@tiptap/extension-placeholder": "2.2.4",
|
||||
"@tiptap/extension-strike": "2.2.4",
|
||||
"@tiptap/extension-table": "2.2.4",
|
||||
"@tiptap/extension-table-cell": "2.2.4",
|
||||
"@tiptap/extension-table-header": "2.2.4",
|
||||
"@tiptap/extension-table-row": "2.2.4",
|
||||
"@tiptap/extension-task-item": "2.2.4",
|
||||
"@tiptap/extension-task-list": "2.2.4",
|
||||
"@tiptap/extension-text": "2.2.4",
|
||||
"@tiptap/extension-typography": "2.2.4",
|
||||
"@tiptap/extension-underline": "2.2.4",
|
||||
"@tiptap/pm": "2.2.4",
|
||||
"@tiptap/suggestion": "2.2.4",
|
||||
"@tiptap/vue-3": "2.2.4",
|
||||
"@sentry/tracing": "7.109.0",
|
||||
"@sentry/vue": "7.109.0",
|
||||
"@tiptap/core": "2.2.6",
|
||||
"@tiptap/extension-blockquote": "2.2.6",
|
||||
"@tiptap/extension-bold": "2.2.6",
|
||||
"@tiptap/extension-bullet-list": "2.2.6",
|
||||
"@tiptap/extension-code": "2.2.6",
|
||||
"@tiptap/extension-code-block-lowlight": "2.2.6",
|
||||
"@tiptap/extension-document": "2.2.6",
|
||||
"@tiptap/extension-dropcursor": "2.2.6",
|
||||
"@tiptap/extension-gapcursor": "2.2.6",
|
||||
"@tiptap/extension-hard-break": "2.2.6",
|
||||
"@tiptap/extension-heading": "2.2.6",
|
||||
"@tiptap/extension-history": "2.2.6",
|
||||
"@tiptap/extension-horizontal-rule": "2.2.6",
|
||||
"@tiptap/extension-image": "2.2.6",
|
||||
"@tiptap/extension-italic": "2.2.6",
|
||||
"@tiptap/extension-link": "2.2.6",
|
||||
"@tiptap/extension-list-item": "2.2.6",
|
||||
"@tiptap/extension-ordered-list": "2.2.6",
|
||||
"@tiptap/extension-paragraph": "2.2.6",
|
||||
"@tiptap/extension-placeholder": "2.2.6",
|
||||
"@tiptap/extension-strike": "2.2.6",
|
||||
"@tiptap/extension-table": "2.2.6",
|
||||
"@tiptap/extension-table-cell": "2.2.6",
|
||||
"@tiptap/extension-table-header": "2.2.6",
|
||||
"@tiptap/extension-table-row": "2.2.6",
|
||||
"@tiptap/extension-task-item": "2.2.6",
|
||||
"@tiptap/extension-task-list": "2.2.6",
|
||||
"@tiptap/extension-text": "2.2.6",
|
||||
"@tiptap/extension-typography": "2.2.6",
|
||||
"@tiptap/extension-underline": "2.2.6",
|
||||
"@tiptap/pm": "2.2.6",
|
||||
"@tiptap/suggestion": "2.2.6",
|
||||
"@tiptap/vue-3": "2.2.6",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.clonedeep": "4.5.9",
|
||||
"@vueuse/core": "10.9.0",
|
||||
|
@ -103,7 +103,7 @@
|
|||
"camel-case": "4.1.2",
|
||||
"date-fns": "3.6.0",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.0.10",
|
||||
"dompurify": "3.1.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.31",
|
||||
|
@ -121,7 +121,7 @@
|
|||
"vue": "3.4.21",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "9.10.2",
|
||||
"vue-i18n": "9.11.0",
|
||||
"vue-router": "4.3.0",
|
||||
"vuemoji-picker": "0.2.1",
|
||||
"workbox-precaching": "7.0.0",
|
||||
|
@ -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.30001607",
|
||||
"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.7.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.1",
|
||||
"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.11",
|
||||
"wait-on": "7.2.0",
|
||||
"workbox-cli": "7.0.0"
|
||||
},
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -188,12 +188,6 @@ $modal-width: 1024px;
|
|||
.info {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -63,6 +63,10 @@ const filteredProjects = computed(() => {
|
|||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
--project-grid-columns: 5;
|
||||
|
||||
.project-grid-item:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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')})
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
class="mt-2"
|
||||
entity-kind="comments"
|
||||
:entity-id="c.id"
|
||||
:disabled="!canWrite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,8 +20,6 @@ export interface IProject extends IAbstract {
|
|||
position: number
|
||||
backgroundBlurHash: string
|
||||
parentProjectId: number
|
||||
doneBucketId: number
|
||||
defaultBucketId: number
|
||||
views: IProjectView[]
|
||||
|
||||
created: Date
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
@ -26,10 +26,10 @@ test('store project in history', () => {
|
|||
expect(saved).toBe('[{"id":1}]')
|
||||
})
|
||||
|
||||
test('store only the last 5 projects in history', () => {
|
||||
test('store only the last 6 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
|
||||
})
|
||||
|
||||
|
@ -39,13 +39,14 @@ test('store only the last 5 projects in history', () => {
|
|||
saveProjectToHistory({id: 4})
|
||||
saveProjectToHistory({id: 5})
|
||||
saveProjectToHistory({id: 6})
|
||||
expect(saved).toBe('[{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
|
||||
saveProjectToHistory({id: 7})
|
||||
expect(saved).toBe('[{"id":7},{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
|
||||
})
|
||||
|
||||
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 +57,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 +70,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
|
||||
})
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ function saveHistory(history: ProjectHistory[]) {
|
|||
localStorage.setItem('projectHistory', JSON.stringify(history))
|
||||
}
|
||||
|
||||
const MAX_SAVED_PROJECTS = 6
|
||||
|
||||
export function saveProjectToHistory(project: ProjectHistory) {
|
||||
const history: ProjectHistory[] = getHistory()
|
||||
|
||||
|
@ -33,7 +35,7 @@ export function saveProjectToHistory(project: ProjectHistory) {
|
|||
// Add the new project to the beginning of the project
|
||||
history.unshift(project)
|
||||
|
||||
if (history.length > 5) {
|
||||
if (history.length > MAX_SAVED_PROJECTS) {
|
||||
history.pop()
|
||||
}
|
||||
saveHistory(history)
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -106,6 +106,12 @@ export const useProjectStore = defineStore('project', () => {
|
|||
}
|
||||
|
||||
function removeProjectById(project: IProject) {
|
||||
|
||||
// Remove child projects from state as well
|
||||
projectsArray.value
|
||||
.filter(p => p.parentProjectId === project.id)
|
||||
.forEach(p => removeProjectById(p))
|
||||
|
||||
remove(project)
|
||||
delete projects.value[project.id]
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
@import "tooltip";
|
||||
@import "labels";
|
||||
@import "project";
|
||||
@import "task";
|
||||
@import "tasks";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
<h1>{{ $t('migrate.titleService', {name: migrator.name}) }}</h1>
|
||||
<p>{{ $t('migrate.descriptionDo') }}</p>
|
||||
|
||||
<template v-if="message === '' && lastMigrationStartedAt === null">
|
||||
<template v-if="message === '' && lastMigrationStartedAt === null && !migrationJustStarted">
|
||||
<template v-if="isMigrating === false">
|
||||
<template v-if="migrator.isFileMigrator">
|
||||
<p>{{ $t('migrate.importUpload', {name: migrator.name}) }}</p>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -328,6 +328,7 @@
|
|||
entity-kind="tasks"
|
||||
:entity-id="task.id"
|
||||
class="details"
|
||||
:disabled="!canWrite"
|
||||
/>
|
||||
|
||||
<!-- Attachments -->
|
||||
|
|
17
go.mod
17
go.mod
|
@ -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
|
||||
|
@ -50,6 +50,7 @@ require (
|
|||
github.com/lib/pq v1.10.9
|
||||
github.com/magefile/mage v1.15.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/pquerna/otp v1.4.0
|
||||
|
@ -66,18 +67,18 @@ require (
|
|||
github.com/typesense/typesense-go v1.0.0
|
||||
github.com/ulule/limiter/v3 v3.11.2
|
||||
github.com/wneessen/go-mail v0.4.0
|
||||
github.com/yuin/goldmark v1.7.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
github.com/yuin/goldmark v1.7.1
|
||||
golang.org/x/crypto v0.22.0
|
||||
golang.org/x/image v0.15.0
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/sys v0.18.0
|
||||
golang.org/x/term v0.18.0
|
||||
golang.org/x/sys v0.19.0
|
||||
golang.org/x/term v0.19.0
|
||||
golang.org/x/text v0.14.0
|
||||
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
|
||||
|
@ -90,6 +91,7 @@ require (
|
|||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
|
@ -121,6 +123,7 @@ require (
|
|||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
|
@ -176,7 +179,7 @@ require (
|
|||
golang.org/x/arch v0.4.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.13.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
|
|
20
go.sum
20
go.sum
|
@ -35,6 +35,8 @@ github.com/arran4/golang-ical v0.2.7 h1:VO7YlVaGupZE15aj6NhUhte/MIfZuoIzkoI71VsG
|
|||
github.com/arran4/golang-ical v0.2.7/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
|
||||
github.com/bbrks/go-blurhash v1.1.1/go.mod h1:lkAsdyXp+EhARcUo85yS2G1o+Sh43I2ebF5togC4bAY=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
|
@ -158,6 +160,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=
|
||||
|
@ -213,6 +217,8 @@ github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
|
@ -370,6 +376,8 @@ github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S
|
|||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
@ -531,6 +539,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
||||
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
|
@ -573,6 +583,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
|
|||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
|
@ -609,6 +621,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -652,6 +666,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
@ -660,6 +676,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
@ -786,6 +804,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=
|
||||
|
|
|
@ -82,6 +82,7 @@ func init() {
|
|||
|
||||
if config.DatabaseType.GetString() == "sqlite" {
|
||||
_, err = tx.Exec(`
|
||||
|
||||
create table buckets_dg_tmp
|
||||
(
|
||||
id INTEGER not null
|
||||
|
@ -99,6 +100,8 @@ insert into buckets_dg_tmp(id, title, "limit", position, created, updated, creat
|
|||
select id, title, "limit", position, created, updated, created_by_id, project_view_id
|
||||
from buckets;
|
||||
|
||||
drop index if exists buckets.UQE_buckets_id;
|
||||
|
||||
drop table buckets;
|
||||
|
||||
alter table buckets_dg_tmp
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -17,10 +17,8 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
|
@ -77,20 +75,16 @@ func (n *TaskCommentNotification) SubjectID() int64 {
|
|||
func (n *TaskCommentNotification) ToMail() *notifications.Mail {
|
||||
|
||||
mail := notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail())
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject("Re: " + n.Task.Title)
|
||||
|
||||
subject := "Re: " + n.Task.Title
|
||||
if n.Mentioned {
|
||||
subject = n.Doer.GetName() + ` mentioned you in a comment in "` + n.Task.Title + `"`
|
||||
mail.Line("**" + n.Doer.GetName() + "** mentioned you in a comment:")
|
||||
mail.
|
||||
Line("**" + n.Doer.GetName() + "** mentioned you in a comment:").
|
||||
Subject(n.Doer.GetName() + ` mentioned you in a comment in "` + n.Task.Title + `"`)
|
||||
}
|
||||
|
||||
mail.Subject(subject)
|
||||
|
||||
lines := bufio.NewScanner(strings.NewReader(n.Comment.Comment))
|
||||
for lines.Scan() {
|
||||
mail.Line(lines.Text())
|
||||
}
|
||||
mail.HTML(n.Comment.Comment)
|
||||
|
||||
return mail.
|
||||
Action("View Task", n.Task.GetFrontendURL())
|
||||
|
@ -306,12 +300,8 @@ func (n *UserMentionedInTaskNotification) ToMail() *notifications.Mail {
|
|||
mail := notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(subject).
|
||||
Line("**" + n.Doer.GetName() + "** mentioned you in a task:")
|
||||
|
||||
lines := bufio.NewScanner(strings.NewReader(n.Task.Description))
|
||||
for lines.Scan() {
|
||||
mail.Line(lines.Text())
|
||||
}
|
||||
Line("**" + n.Doer.GetName() + "** mentioned you in a task:").
|
||||
HTML(n.Task.Description)
|
||||
|
||||
return mail.
|
||||
Action("View Task", n.Task.GetFrontendURL())
|
||||
|
|
|
@ -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(¤tProjects)
|
||||
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(¤tProjects)
|
||||
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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -143,6 +143,13 @@ func (bp *BackgroundProvider) SetBackground(c echo.Context) error {
|
|||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
err = project.ReadOne(s, auth)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, project)
|
||||
}
|
||||
|
||||
|
|
|
@ -18,19 +18,32 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
func RegisterListeners() {
|
||||
events.RegisterListener((&MigrationRequestedEvent{}).Name(), &MigrationListener{})
|
||||
}
|
||||
|
||||
// Only used for sentry
|
||||
type migrationFailedError struct {
|
||||
MigratorKind string
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
func (m *migrationFailedError) Error() string {
|
||||
return fmt.Sprintf("migration from %s failed, original error message was: %s", m.MigratorKind, m.OriginalError.Error())
|
||||
}
|
||||
|
||||
// MigrationListener represents a listener
|
||||
type MigrationListener struct {
|
||||
}
|
||||
|
@ -51,7 +64,7 @@ func (s *MigrationListener) Handle(msg *message.Message) (err error) {
|
|||
mstr := registeredMigrators[event.MigratorKind]
|
||||
event.Migrator = mstr.MigrationStruct()
|
||||
|
||||
// unmarshaling again to make sure the migrator has the correct type now
|
||||
// unmarshalling again to make sure the migrator has the correct type now
|
||||
err = json.Unmarshal(msg.Payload, event)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -59,13 +72,46 @@ func (s *MigrationListener) Handle(msg *message.Message) (err error) {
|
|||
|
||||
ms := event.Migrator.(migration.Migrator)
|
||||
|
||||
m, err := migration.StartMigration(ms, event.User)
|
||||
m, err := migrateInListener(ms, event)
|
||||
if err != nil {
|
||||
log.Errorf("[Migration] Migration %d from %s for user %d failed. Error was: %s", m.ID, event.MigratorKind, event.User.ID, err.Error())
|
||||
|
||||
var nerr error
|
||||
if config.SentryEnabled.GetBool() {
|
||||
nerr = notifications.Notify(event.User, &MigrationFailedReportedNotification{
|
||||
MigratorName: ms.Name(),
|
||||
})
|
||||
sentry.CaptureException(&migrationFailedError{
|
||||
MigratorKind: event.MigratorKind,
|
||||
OriginalError: err,
|
||||
})
|
||||
} else {
|
||||
nerr = notifications.Notify(event.User, &MigrationFailedNotification{
|
||||
MigratorName: ms.Name(),
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
if nerr != nil {
|
||||
log.Errorf("[Migration] Could not sent failed migration notification for migration %d to user %d, error was: %s", m.ID, event.User.ID, err.Error())
|
||||
}
|
||||
|
||||
// Still need to finish the migration, otherwise restarting will not work
|
||||
err = migration.FinishMigration(m)
|
||||
if err != nil {
|
||||
log.Errorf("[Migration] Could not finish migration %d for user %d, error was: %s", m.ID, event.User.ID, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil // We do not want the queue to restart this job as we've already handled the error.
|
||||
}
|
||||
|
||||
func migrateInListener(ms migration.Migrator, event *MigrationRequestedEvent) (m *migration.Status, err error) {
|
||||
m, err = migration.StartMigration(ms, event.User)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("[Migration] Starting migration %d from %s for user %d", m.ID, event.MigratorKind, event.User.ID)
|
||||
|
||||
err = ms.Migrate(event.User)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -73,6 +119,7 @@ func (s *MigrationListener) Handle(msg *message.Message) (err error) {
|
|||
|
||||
err = migration.FinishMigration(m)
|
||||
if err != nil {
|
||||
log.Errorf("[Migration] Could not finish migration %d for user %d, error was: %s", m.ID, event.User.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -80,6 +127,7 @@ func (s *MigrationListener) Handle(msg *message.Message) (err error) {
|
|||
MigratorName: ms.Name(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("[Migration] Could not sent migration success notification for migration %d to user %d, error was: %s", m.ID, event.User.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -49,3 +49,57 @@ func (n *MigrationDoneNotification) ToDB() interface{} {
|
|||
func (n *MigrationDoneNotification) Name() string {
|
||||
return "migration.done"
|
||||
}
|
||||
|
||||
// MigrationFailedReportedNotification represents a MigrationFailedReportedNotification notification
|
||||
type MigrationFailedReportedNotification struct {
|
||||
MigratorName string
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for MigrationFailedReportedNotification
|
||||
func (n *MigrationFailedReportedNotification) ToMail() *notifications.Mail {
|
||||
kind := cases.Title(language.English).String(n.MigratorName)
|
||||
|
||||
return notifications.NewMail().
|
||||
Subject("The migration from " + kind + " to Vikunja was has failed").
|
||||
Line("Looks like the move from " + kind + " didn't go as planned this time.").
|
||||
Line("No worries, though! Just give it another shot by starting over the same way you did before. Sometimes, these hiccups happen because of glitches on " + kind + "'s end, but trying again often does the trick.").
|
||||
Line("We've got the error message on our radar and are on it to get it sorted out soon.")
|
||||
}
|
||||
|
||||
// ToDB returns the MigrationFailedReportedNotification notification in a format which can be saved in the db
|
||||
func (n *MigrationFailedReportedNotification) ToDB() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the name of the notification
|
||||
func (n *MigrationFailedReportedNotification) Name() string {
|
||||
return "migration.failed.reported"
|
||||
}
|
||||
|
||||
// MigrationFailedNotification represents a MigrationFailedNotification notification
|
||||
type MigrationFailedNotification struct {
|
||||
MigratorName string
|
||||
Error error
|
||||
}
|
||||
|
||||
// ToMail returns the mail notification for MigrationFailedNotification
|
||||
func (n *MigrationFailedNotification) ToMail() *notifications.Mail {
|
||||
kind := cases.Title(language.English).String(n.MigratorName)
|
||||
|
||||
return notifications.NewMail().
|
||||
Subject("The migration from " + kind + " to Vikunja was has failed").
|
||||
Line("Looks like the move from " + kind + " didn't go as planned this time.").
|
||||
Line("No worries, though! Just give it another shot by starting over the same way you did before. Sometimes, these hiccups happen because of glitches on " + kind + "'s end, but trying again often does the trick.").
|
||||
Line("We bumped into a little error along the way: `" + n.Error.Error() + "`.").
|
||||
Line("Please drop us a note about this [in the forum](https://community.vikunja.io/) or any of the usual places so that we can take a look at why it failed.")
|
||||
}
|
||||
|
||||
// ToDB returns the MigrationFailedNotification notification in a format which can be saved in the db
|
||||
func (n *MigrationFailedNotification) ToDB() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the name of the notification
|
||||
func (n *MigrationFailedNotification) Name() string {
|
||||
return "migration.failed"
|
||||
}
|
||||
|
|
|
@ -26,8 +26,13 @@ type Mail struct {
|
|||
actionText string
|
||||
actionURL string
|
||||
greeting string
|
||||
introLines []string
|
||||
outroLines []string
|
||||
introLines []*mailLine
|
||||
outroLines []*mailLine
|
||||
}
|
||||
|
||||
type mailLine struct {
|
||||
Text string
|
||||
isHTML bool
|
||||
}
|
||||
|
||||
// NewMail creates a new mail object with a default greeting
|
||||
|
@ -66,14 +71,28 @@ func (m *Mail) Action(text, url string) *Mail {
|
|||
return m
|
||||
}
|
||||
|
||||
// Line adds a line of text to the mail
|
||||
// Line adds a line of Text to the mail
|
||||
func (m *Mail) Line(line string) *Mail {
|
||||
return m.appendLine(line, false)
|
||||
}
|
||||
|
||||
func (m *Mail) HTML(line string) *Mail {
|
||||
return m.appendLine(line, true)
|
||||
}
|
||||
|
||||
func (m *Mail) appendLine(line string, isHTML bool) *Mail {
|
||||
if m.actionURL == "" {
|
||||
m.introLines = append(m.introLines, line)
|
||||
m.introLines = append(m.introLines, &mailLine{
|
||||
Text: line,
|
||||
isHTML: isHTML,
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
m.outroLines = append(m.outroLines, line)
|
||||
m.outroLines = append(m.outroLines, &mailLine{
|
||||
Text: line,
|
||||
isHTML: isHTML,
|
||||
})
|
||||
|
||||
return m
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import (
|
|||
templatehtml "html/template"
|
||||
templatetext "text/template"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/mail"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
|
@ -33,12 +35,12 @@ import (
|
|||
const mailTemplatePlain = `
|
||||
{{ .Greeting }}
|
||||
{{ range $line := .IntroLines}}
|
||||
{{ $line }}
|
||||
{{ $line.Text }}
|
||||
{{ end }}
|
||||
{{ if .ActionURL }}{{ .ActionText }}:
|
||||
{{ .ActionURL }}{{end}}
|
||||
{{ range $line := .OutroLines}}
|
||||
{{ $line }}
|
||||
{{ $line.Text }}
|
||||
{{ end }}`
|
||||
|
||||
const mailTemplateHTML = `
|
||||
|
@ -48,9 +50,9 @@ const mailTemplateHTML = `
|
|||
<meta name="viewport" content="width: display-width;">
|
||||
</head>
|
||||
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
|
||||
<div style="width: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
|
||||
<div style="width: 600px; margin: 0 auto; text-align: justify;">
|
||||
<h1 style="font-size: 30px; text-align: center;">
|
||||
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
|
||||
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
|
||||
<h1 style="font-size: 30px; Text-align: center;">
|
||||
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
|
||||
</h1>
|
||||
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
|
||||
|
@ -64,7 +66,7 @@ const mailTemplateHTML = `
|
|||
|
||||
{{ if .ActionURL }}
|
||||
<a href="{{ .ActionURL }}" title="{{ .ActionText }}"
|
||||
style="position: relative;text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;text-align: center;white-space: nowrap;border: 0;text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
|
||||
style="position: relative;Text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;Text-align: center;white-space: nowrap;border: 0;Text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
|
||||
{{ .ActionText }}
|
||||
</a>
|
||||
{{end}}
|
||||
|
@ -117,29 +119,43 @@ func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) {
|
|||
data["Boundary"] = boundary
|
||||
data["FrontendURL"] = config.ServicePublicURL.GetString()
|
||||
|
||||
p := bluemonday.UGCPolicy()
|
||||
|
||||
var introLinesHTML []templatehtml.HTML
|
||||
for _, line := range m.introLines {
|
||||
md := []byte(templatehtml.HTMLEscapeString(line))
|
||||
if line.isHTML {
|
||||
// #nosec G203 -- the html is sanitized
|
||||
introLinesHTML = append(introLinesHTML, templatehtml.HTML(p.Sanitize(line.Text)))
|
||||
continue
|
||||
}
|
||||
|
||||
md := []byte(templatehtml.HTMLEscapeString(line.Text))
|
||||
var buf bytes.Buffer
|
||||
err = goldmark.Convert(md, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//#nosec - the html is escaped few lines before
|
||||
introLinesHTML = append(introLinesHTML, templatehtml.HTML(buf.String()))
|
||||
// #nosec G203 -- the html is sanitized
|
||||
introLinesHTML = append(introLinesHTML, templatehtml.HTML(p.Sanitize(buf.String())))
|
||||
}
|
||||
data["IntroLinesHTML"] = introLinesHTML
|
||||
|
||||
var outroLinesHTML []templatehtml.HTML
|
||||
for _, line := range m.outroLines {
|
||||
md := []byte(templatehtml.HTMLEscapeString(line))
|
||||
if line.isHTML {
|
||||
// #nosec G203 -- the html is sanitized
|
||||
outroLinesHTML = append(outroLinesHTML, templatehtml.HTML(p.Sanitize(line.Text)))
|
||||
continue
|
||||
}
|
||||
|
||||
md := []byte(templatehtml.HTMLEscapeString(line.Text))
|
||||
var buf bytes.Buffer
|
||||
err = goldmark.Convert(md, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//#nosec - the html is escaped few lines before
|
||||
outroLinesHTML = append(outroLinesHTML, templatehtml.HTML(buf.String()))
|
||||
// #nosec G203 -- the html is sanitized
|
||||
outroLinesHTML = append(outroLinesHTML, templatehtml.HTML(p.Sanitize(buf.String())))
|
||||
}
|
||||
data["OutroLinesHTML"] = outroLinesHTML
|
||||
|
||||
|
|
|
@ -41,11 +41,15 @@ func TestNewMail(t *testing.T) {
|
|||
assert.Equal(t, "Testmail", mail.subject)
|
||||
assert.Equal(t, "Hi there,", mail.greeting)
|
||||
assert.Len(t, mail.introLines, 2)
|
||||
assert.Equal(t, "This is a line", mail.introLines[0])
|
||||
assert.Equal(t, "And another one", mail.introLines[1])
|
||||
assert.Equal(t, "This is a line", mail.introLines[0].Text)
|
||||
assert.False(t, mail.introLines[0].isHTML)
|
||||
assert.Equal(t, "And another one", mail.introLines[1].Text)
|
||||
assert.False(t, mail.introLines[1].isHTML)
|
||||
assert.Len(t, mail.outroLines, 2)
|
||||
assert.Equal(t, "This should be an outro line", mail.outroLines[0])
|
||||
assert.Equal(t, "And one more, because why not?", mail.outroLines[1])
|
||||
assert.Equal(t, "This should be an outro line", mail.outroLines[0].Text)
|
||||
assert.False(t, mail.outroLines[0].isHTML)
|
||||
assert.Equal(t, "And one more, because why not?", mail.outroLines[1].Text)
|
||||
assert.False(t, mail.outroLines[1].isHTML)
|
||||
})
|
||||
t.Run("No greeting", func(t *testing.T) {
|
||||
mail := NewMail().
|
||||
|
@ -60,8 +64,8 @@ func TestNewMail(t *testing.T) {
|
|||
assert.Equal(t, "Testmail", mail.subject)
|
||||
assert.Equal(t, "", mail.greeting)
|
||||
assert.Len(t, mail.introLines, 2)
|
||||
assert.Equal(t, "This is a line", mail.introLines[0])
|
||||
assert.Equal(t, "And another one", mail.introLines[1])
|
||||
assert.Equal(t, "This is a line", mail.introLines[0].Text)
|
||||
assert.Equal(t, "And another one", mail.introLines[1].Text)
|
||||
})
|
||||
t.Run("No action", func(t *testing.T) {
|
||||
mail := NewMail().
|
||||
|
@ -77,10 +81,10 @@ func TestNewMail(t *testing.T) {
|
|||
assert.Equal(t, "test@otherdomain.com", mail.to)
|
||||
assert.Equal(t, "Testmail", mail.subject)
|
||||
assert.Len(t, mail.introLines, 4)
|
||||
assert.Equal(t, "This is a line", mail.introLines[0])
|
||||
assert.Equal(t, "And another one", mail.introLines[1])
|
||||
assert.Equal(t, "This should be an outro line", mail.introLines[2])
|
||||
assert.Equal(t, "And one more, because why not?", mail.introLines[3])
|
||||
assert.Equal(t, "This is a line", mail.introLines[0].Text)
|
||||
assert.Equal(t, "And another one", mail.introLines[1].Text)
|
||||
assert.Equal(t, "This should be an outro line", mail.introLines[2].Text)
|
||||
assert.Equal(t, "And one more, because why not?", mail.introLines[3].Text)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -125,9 +129,9 @@ And one more, because why not?
|
|||
<meta name="viewport" content="width: display-width;">
|
||||
</head>
|
||||
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
|
||||
<div style="width: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
|
||||
<div style="width: 600px; margin: 0 auto; text-align: justify;">
|
||||
<h1 style="font-size: 30px; text-align: center;">
|
||||
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
|
||||
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
|
||||
<h1 style="font-size: 30px; Text-align: center;">
|
||||
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
|
||||
</h1>
|
||||
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
|
||||
|
@ -139,7 +143,7 @@ And one more, because why not?
|
|||
<p>This is a line</p>
|
||||
|
||||
|
||||
<p>This <strong>line</strong> contains <a href="https://vikunja.io">a link</a></p>
|
||||
<p>This <strong>line</strong> contains <a href="https://vikunja.io" rel="nofollow">a link</a></p>
|
||||
|
||||
|
||||
<p>And another one</p>
|
||||
|
@ -148,7 +152,7 @@ And one more, because why not?
|
|||
|
||||
|
||||
<a href="https://example.com" title="The action"
|
||||
style="position: relative;text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;text-align: center;white-space: nowrap;border: 0;text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
|
||||
style="position: relative;Text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;Text-align: center;white-space: nowrap;border: 0;Text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
|
||||
The action
|
||||
</a>
|
||||
|
||||
|
|
Loading…
Reference in New Issue