Compare commits

..

1 Commits

Author SHA1 Message Date
89a9f5a779 fix(deps): update dependency flexsearch to v0.7.31 2023-02-15 22:03:43 +00:00
282 changed files with 19191 additions and 13814 deletions

View File

@ -15,7 +15,6 @@ trigger:
services:
- name: api
image: vikunja/api:unstable
pull: always
environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
@ -42,12 +41,11 @@ steps:
# - .cache
- name: dependencies
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
PUPPETEER_SKIP_DOWNLOAD: true
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
@ -55,7 +53,7 @@ steps:
# - restore-cache
- name: lint
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -66,7 +64,7 @@ steps:
- dependencies
- name: build-prod
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -77,7 +75,7 @@ steps:
- dependencies
- name: test-unit
image: node:20-alpine
image: node:18-alpine
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -87,7 +85,7 @@ steps:
- name: typecheck
failure: ignore
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -137,9 +135,8 @@ steps:
# - dependencies
- name: deploy-preview
image: williamjackson/netlify-cli
image: node:18-alpine
pull: always
user: root # The rest runs as root and thus the permissions wouldn't work
environment:
NETLIFY_AUTH_TOKEN:
from_secret: netlify_auth_token
@ -202,7 +199,7 @@ steps:
# - .cache
- name: build
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -279,7 +276,7 @@ steps:
# - .cache
- name: build
image: node:20-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -524,6 +521,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: 511c2a090e9efd4c942980d971204adb6321540bb01c92409dd9bf8463b7f6f4
hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
...

14
.npmrc
View File

@ -1,14 +1,2 @@
fetch-timeout=100000
# pnpm settings
# The following settings prepare for the new default value of pnpm 8
# they can be removed directly after having moved to pnpm 8
auto-install-peers=true
dedupe-peer-dependents=true
resolve-peers-from-workspace-root=true
save-workspace-protocol=rolling
resolution-mode=lowest-direct
publishConfig.linkDirectory=true
# remove some time after having moved to pnpm 8
use-lockfile-v6=true
fetch-timeout=100000

2
.nvmrc
View File

@ -1 +1 @@
18.16.0
v18

View File

@ -8,7 +8,6 @@
"lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel",
"mikestead.dotenv",
"Syler.sass-indented",
"zixuanchen.vitest-explorer"
"Syler.sass-indented"
]
}

View File

@ -18,12 +18,6 @@
"javascriptreact",
"vue"
],
"volar.completion.preferredTagNameCase": "pascal",
// disable vetur in case it is installed
"vetur.validation.template": false,
// i18n ally
"i18n-ally.localesPaths": [
"src/i18n/lang"

View File

@ -9,279 +9,6 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.20.5] - 2023-03-12
### Bug Fixes
* *(docker)* Add cap_net_bind to the nginx binary in the docker container
* *(docker)* Revert unprivileged user
### Dependencies
* *(deps)* Update dependency sass to v1.59.2
* *(deps)* Update dependency eslint to v8.36.0
## [0.20.4] - 2023-03-10
### Bug Fixes
* *(base)* Use Build Time Base Path
* *(docker)* Cross compilation with buildx
* *(docker)* Default api url
* *(docker)* Make sure the service worker and webmanifest are never cached
* *(filter)* Validate title before creating or editing a filter
* *(filter)* Don't allow marking a filter as favorite
* *(i18n)* Load language files before doing anything else (#3218)
* *(keyboard-shortcuts)* Use card prop
* *(list)* Make sure favorite lists are not duplicated in the menu when renaming them
* *(menu)* Don't show drag handle for not draggable menu items
* *(postcss-preset-env)* Client side polyfills (#3051)
* *(quick actions)* Don't throw an error message when selecting the last items with the arrow keys
* *(quick actions)* Hide edges of last entry on hover
* *(quick add magic)* Correctly parse "next {weekday}" on the beginning of the text
* *(quick-actions)* Nothing happening on team click (#3186)
* *(table view)* Correctly load sort order from local storage
* *(task)* Allow clicking on the whole task to open the task detail view
* *(tests)* Only look in src for tests
* Make sure global error handler handles unrejected promises correctly ([4576da0](4576da0dd394ee68801b1dc424c9550896d63737))
* Use Build Time Base Path (#2964) ([6572f75](6572f75e5d111f7f2dd06e8c2ad0e0d16091fca6))
* Always show update popup on top ([7cbf0ac](7cbf0acac503c508a44e0491ae51e6d5749dfa04))
* Button styles ([d40729c](d40729cbe70b760bcc64d56130a410b05ef9d3dc))
* Stop revealing elements on hover if hover is not supported (#3191) ([7b6f76d](7b6f76d1b4698d0d6c6889aaab3f1cdad80469f8))
* Sync sidebar transition with `<main>` (#3200) ([0f97ba6](0f97ba6ec904226ed91cd3ade8223e2959e9207a))
* Collapse menu on mobile when path changes ([1b06112](1b06112db4ba5ad4144b5868dd04e954be1d77f7))
* I18ze a string (#3210) ([b4dd23b](b4dd23b85d909f7e629e953f1d8543ccbf963a1c))
### Dependencies
* *(deps)* Update sentry-javascript monorepo to v7.33.0 (#3004)
* *(deps)* Update dependency axios to v1.2.4 (#3005)
* *(deps)* Update pnpm to v7.26.0 (#3002)
* *(deps)* Update dependency cypress to v12.4.0 (#3006)
* *(deps)* Update dependency @infectoone/vue-ganttastic to v2.1.4 (#3009)
* *(deps)* Update dependency vitest to v0.28.2 (#3008)
* *(deps)* Update dependency rollup to v3.11.0 (#3013)
* *(deps)* Update dependency @vitejs/plugin-legacy to v3.0.2 (#3012)
* *(deps)* Update dependency axios to v1.2.5
* *(deps)* Update sentry-javascript monorepo to v7.34.0
* *(deps)* Update pnpm to v7.26.1
* *(deps)* Update dependency @vue/test-utils to v2.2.8
* *(deps)* Update dependency vitest to v0.28.3 (#3019)
* *(deps)* Update dependency cypress to v12.4.1
* *(deps)* Update dependency rollup to v3.12.0
* *(deps)* Update dependency esbuild to v0.17.5
* *(deps)* Update dependency axios to v1.2.6
* *(deps)* Update dependency @vueuse/core to v9.12.0
* *(deps)* Update pnpm to v7.26.2
* *(deps)* Update dependency eslint to v8.33.0
* *(deps)* Update dependency netlify-cli to v12.10.0
* *(deps)* Update dependency happy-dom to v8.2.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001449
* *(deps)* Update dependency typescript to v4.9.5
* *(deps)* Update typescript-eslint monorepo to v5.50.0
* *(deps)* Update dependency axios to v1.3.0 (#3036)
* *(deps)* Update dependency sass to v1.58.0
* *(deps)* Update dependency cypress to v12.5.0
* *(deps)* Update pnpm to v7.26.3
* *(deps)* Update dependency rollup to v3.12.1
* *(deps)* Update sentry-javascript monorepo to v7.35.0 (#3041)
* *(deps)* Update dependency pinia to v2.0.30 (#3042)
* *(deps)* Update dependency @vue/test-utils to v2.2.9
* *(deps)* Update dependency axios to v1.3.1
* *(deps)* Update dependency vue to v3.2.47
* *(deps)* Update dependency vite to v4.1.0
* *(deps)* Update dependency postcss-preset-env to v8 (#3000)
* *(deps)* Update dependency @vitejs/plugin-legacy to v4
* *(deps)* Update dependency @vitejs/plugin-legacy to v4.0.1
* *(deps)* Update sentry-javascript monorepo to v7.36.0
* *(deps)* Update dependency vite to v4.1.1
* *(deps)* Update dependency cypress to v12.5.1
* *(deps)* Update dependency @vue/test-utils to v2.2.10
* *(deps)* Update dependency vitest to v0.28.4
* *(deps)* Update dependency rollup to v3.13.0
* *(deps)* Update dependency axios to v1.3.2
* *(deps)* Update dependency rollup to v3.14.0
* *(deps)* Update dependency @types/node to v18.11.19
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.0
* *(deps)* Update dependency histoire to v0.13.0
* *(deps)* Update caniuse-and-related
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.0
* *(deps)* Update dependency happy-dom to v8.2.6
* *(deps)* Update typescript-eslint monorepo to v5.51.0
* *(deps)* Update dependency esbuild to v0.17.6
* *(deps)* Update dependency @cypress/vue to v5.0.4
* *(deps)* Update dependency @types/node to v18.13.0
* *(deps)* Update dependency vite-plugin-pwa to v0.14.2
* *(deps)* Update font awesome to v6.3.0
* *(deps)* Update pnpm to v7.27.0
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.1
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.1
* *(deps)* Update dependency vite-plugin-pwa to v0.14.3
* *(deps)* Update dependency histoire to v0.13.1
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.2
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.2
* *(deps)* Update dependency histoire to v0.13.2
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.8.2
* *(deps)* Update sentry-javascript monorepo to v7.37.0
* *(deps)* Update dependency esbuild to v0.17.7
* *(deps)* Update dependency rollup to v3.15.0
* *(deps)* Create a group for all histoire dependencies
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.14.0
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
* *(deps)* Update dependency histoire to v0.14.0
* *(deps)* Update sentry-javascript monorepo to v7.37.1
* *(deps)* Update dependency histoire to v0.14.2
* *(deps)* Include histoire main package in histoire renovate group
* *(deps)* Histoire renovate group
* *(deps)* Update dependency eslint to v8.34.0
* *(deps)* Update histoire to v0.14.2
* *(deps)* Update dependency vite-plugin-pwa to v0.14.4
* *(deps)* Update dependency esbuild to v0.17.8
* *(deps)* Update dependency netlify-cli to v12.12.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001451
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.0
* *(deps)* Update dependency vitest to v0.28.5
* *(deps)* Update sentry-javascript monorepo to v7.37.2
* *(deps)* Update dependency dompurify to v3 (#3107)
* *(deps)* Update typescript-eslint monorepo to v5.52.0
* *(deps)* Update dependency axios to v1.3.3
* *(deps)* Update dependency start-server-and-test to v1.15.4 (#3109)
* *(deps)* Update dependency sass to v1.58.1
* *(deps)* Update dependency vue-flatpickr-component to v11.0.2 (#3112)
* *(deps)* Update dependency @kyvg/vue3-notification to v2.9.0 (#3113)
* *(deps)* Update histoire to v0.15.1
* *(deps)* Update histoire to v0.15.3
* *(deps)* Update dependency vue-tsc to v1.1.0
* *(deps)* Pin node.js to 18.14.0
* *(deps)* Update dependency cypress to v12.6.0 (#3115)
* *(deps)* Update histoire to v0.15.4
* *(deps)* Update dependency vue-tsc to v1.1.2
* *(deps)* Update dependency sass to v1.58.2
* *(deps)* Update dependency ufo to v1.1.0
* *(deps)* Update node.js to v18.14.1
* *(deps)* Update dependency vite to v4.1.2
* *(deps)* Update sentry-javascript monorepo to v7.38.0
* *(deps)* Update dependency rollup to v3.16.0
* *(deps)* Update histoire to v0.15.7
* *(deps)* Update dependency blurhash to v2.0.5
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.3
* *(deps)* Update dependency @types/node to v18.14.0
* *(deps)* Update histoire to v0.15.8
* *(deps)* Update dependency @vueuse/core to v9.13.0
* *(deps)* Update dependency rollup to v3.17.0
* *(deps)* Update pnpm to v7.27.1
* *(deps)* Update dependency vue-tsc to v1.1.3
* *(deps)* Update dependency sass to v1.58.3
* *(deps)* Update dependency rollup to v3.17.1
* *(deps)* Update dependency esbuild to v0.17.9
* *(deps)* Update dependency vite to v4.1.3
* *(deps)* Update dependency @vue/test-utils to v2.3.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001457
* *(deps)* Update dependency codemirror to v5.65.12
* *(deps)* Update dependency pinia to v2.0.31
* *(deps)* Update dependency vue-tsc to v1.1.4
* *(deps)* Update dependency rollup to v3.17.2
* *(deps)* Update dependency happy-dom to v8.6.0
* *(deps)* Update dependency netlify-cli to v12.13.2
* *(deps)* Update dependency esbuild to v0.17.10
* *(deps)* Update typescript-eslint monorepo to v5.53.0
* *(deps)* Update dependency vue-tsc to v1.1.5
* *(deps)* Update dependency pinia to v2.0.32
* *(deps)* Update node.js to v18.14.2
* *(deps)* Update dependency vite to v4.1.4
* *(deps)* Update dependency vue-tsc to v1.1.7
* *(deps)* Update dependency axios to v1.3.4
* *(deps)* Update dependency @types/node to v18.14.1
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.4
* *(deps)* Update dependency cypress to v12.7.0
* *(deps)* Update dependency vue-tsc to v1.2.0
* *(deps)* Update dependency vitest to v0.29.1
* *(deps)* Update pnpm to v7.28.0
* *(deps)* Update dependency eslint to v8.35.0
* *(deps)* Update dependency rollup to v3.17.3
* *(deps)* Update dependency netlify-cli to v13
* *(deps)* Update dependency happy-dom to v8.9.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001458
* *(deps)* Update dependency start-server-and-test to v1.15.5
* *(deps)* Update dependency start-server-and-test to v2
* *(deps)* Update dependency @types/node to v18.14.2
* *(deps)* Update sentry-javascript monorepo to v7.39.0
* *(deps)* Update typescript-eslint monorepo to v5.54.0
* *(deps)* Update dependency ufo to v1.1.1
* *(deps)* Update dependency vitest to v0.29.2
* *(deps)* Update dependency rollup to v3.18.0
* *(deps)* Update dependency dompurify to v3.0.1
* *(deps)* Update sentry-javascript monorepo to v7.40.0
* *(deps)* Update dependency @types/node to v18.14.4
* *(deps)* Update dependency @types/node to v18.14.5
* *(deps)* Update dependency @types/node to v18.14.6
* *(deps)* Update dependency esbuild to v0.17.11
* *(deps)* Update dependency netlify-cli to v13.0.1
* *(deps)* Update dependency caniuse-lite to v1.0.30001460
* *(deps)* Update pnpm to v7.29.0
* *(deps)* Update sentry-javascript monorepo to v7.41.0
* *(deps)* Update typescript-eslint monorepo to v5.54.1
* *(deps)* Update dependency pinia to v2.0.33
* *(deps)* Update node.js to v18.15.0
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.0
* *(deps)* Update pnpm to v7.29.1
* *(deps)* Update dependency @vue/test-utils to v2.3.1
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.1
* *(deps)* Update sentry-javascript monorepo to v7.42.0
* *(deps)* Update dependency rollup to v3.19.0
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.1
* *(deps)* Update dependency @types/node to v18.15.0
* *(deps)* Update dependency autoprefixer to v10.4.14
* *(deps)* Update dependency rollup to v3.19.1
### Features
* *(config)* Support Setting Base Path in .env
* Use v-show for navigation buttons ([7ed1a37](7ed1a37de53cb8c15994e9524a52080170db5950))
* Unindent settings page (#2996) ([13a39be](13a39be3de4d0f7e0f6be9c20e0464e86b87c676))
* Small content auth improvements (#2998) ([2be7847](2be784766f54810f8969e48291ce9181f2854a5b))
* Move update from navigation to app ([3db5ea4](3db5ea45d768d10458eaab0f5ee9dad0df2996e4))
* Improve naming and styles ([eaeddda](eaeddda4e468c2040862d18c9b2d37a1c0ba099e))
* Use klona instead of lodash.clonedeep (#3073) ([7b96397](7b96397e3bfa43a393ca84439069290bc4c8a5c8))
* Refactor to composable ([c502f9b](c502f9b840ee2d65193aa4ef29c7f260b49db0d2))
* Header improvements ([e8db2c2](e8db2c2b458bcae592609d5a5bc3f1b333651b25))
* Persistent menuActive state with Local Storage (#3011) ([e3dd4ef](e3dd4ef78ac818add138d0323bf65abe8a4caa29))
* Fix calculation of token invalidation (#3077) ([d6b55c7](d6b55c757067413bbc34acd48af9fb553f36db8a))
* Use renovate js-app as preset (#3087) ([97c8970](97c8970dd60b2ba1e894ca0039524c8f6a5cd5df))
* Improve recommended vscode settings ([e0f0699](e0f06999beb0a9fb5da817323744307401e85e47))
### Miscellaneous Tasks
* *(refactor)* Improve `stores/config` types (#3190)
* *(services)* Add examples for some functions
* *(services)* Let `getAll`: always return `Model[]`
* Move class name to top ([c6ed925](c6ed9254247efeb43e0763e095b145d6ec1965e1))
* Simplify error handling for login and OpenId Auth ([e67088f](e67088fdb7bd3b24cea6ee37851ef45f1fb7bdad))
* Simplify getting the error text from an exception ([9adf1ab](9adf1aba895a02f416148ddf8b6925689d6e2687))
* Typo ([81a4f2d](81a4f2d9775716bc0056348664fc24185af040d4))
* Update funding links ([7cb0cd2](7cb0cd293d6d277172eccf2558a62427bc86dfe6))
* Update funding links ([b26ea45](b26ea45fe0d1d6f5f070ef42a5d68aa6db8e6b70))
* Remove minimist dependency (not used anywhere) ([f697640](f697640636466e8f035c7d31597ee589379fa017))
* Remove sponsor ([fa0e46a](fa0e46a3991ab423c9364b65439d9e8e5a28cb7b))
* Histoire add logo link ([af4a039](af4a039502b29e9e7e21cf30d44715c7af056c15))
* Improve `@/message` `action` type (#3209) ([0eb78e3](0eb78e32f994e7032725e38d564320a5a04cbf2a))
* Remove an unused duplicate key ([9db3aed](9db3aedde9566fb94717e1dd66a21abdbda6e84a))
### Other
* *(other)* Add Ipv6 support to nginx (#100)
* *(other)* Added ipv6 control script
* *(other)* Disable listening on IPv6 ports when IPv6 is not supported (#102)
* *(other)* Docker refactoring (#3018)
* *(other)* Persist menuActive state in Local Storage
* *(other)* Refactor to only used local storage value when on desktop viewport widths
* *(other)* Solve for resize()
* *(other)* [skip ci] Updated translations via Crowdin
## [0.20.3] - 2023-01-24
### Bug Fixes

View File

@ -3,7 +3,7 @@
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM node:18-alpine AS builder
WORKDIR /build
@ -54,7 +54,6 @@ ENV VIKUNJA_LOG_FORMAT main
ENV VIKUNJA_API_URL /api/v1
ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
@ -67,3 +66,5 @@ RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
chmod -R 0644 /etc/nginx/nginx.conf && \
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
# unprivileged user
USER nginx

View File

@ -4,7 +4,7 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.20.5-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.20.3-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.

View File

@ -24,5 +24,4 @@ export default defineConfig({
},
viewportWidth: 1600,
viewportHeight: 900,
experimentalMemoryManagement: true,
})

View File

@ -0,0 +1,57 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
describe('List History', () => {
createFakeUserAndLogin()
prepareLists()
it('should show a list history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
const lists = ListFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="listCardGrid"]')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})

View File

@ -3,15 +3,15 @@ import {formatISO, format} from 'date-fns'
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {prepareLists} from './prepareLists'
describe('Project View Gantt', () => {
describe('List View Gantt', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
@ -25,7 +25,7 @@ describe('Project View Gantt', () => {
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
@ -38,7 +38,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
@ -50,7 +50,7 @@ describe('Project View Gantt', () => {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
@ -69,7 +69,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -83,9 +83,9 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first()
@ -99,13 +99,13 @@ describe('Project View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
})
@ -115,7 +115,7 @@ describe('Project View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -1,13 +1,13 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {prepareLists} from './prepareLists'
describe('Project View Kanban', () => {
describe('List View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
let buckets
beforeEach(() => {
@ -16,10 +16,10 @@ describe('Project View Kanban', () => {
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -34,10 +34,10 @@ describe('Project View Kanban', () => {
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
@ -55,7 +55,7 @@ describe('Project View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -69,7 +69,7 @@ describe('Project View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -90,7 +90,7 @@ describe('Project View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
@ -101,7 +101,7 @@ describe('Project View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -125,10 +125,10 @@ describe('Project View Kanban', () => {
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -144,10 +144,10 @@ describe('Project View Kanban', () => {
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -158,18 +158,18 @@ describe('Project View Kanban', () => {
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}',
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
@ -180,7 +180,7 @@ describe('Project View Kanban', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -197,26 +197,26 @@ describe('Project View Kanban', () => {
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.project-kanban .filter-container .base-button')
cy.get('.list-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const projects = ProjectFactory.create(1)
const lists = ListFactory.create(1)
const buckets = BucketFactory.create(2, {
project_id: projects[0].id,
list_id: lists[0].id,
})
const tasks = TaskFactory.create(5, {
project_id: 1,
list_id: 1,
bucket_id: buckets[0].id,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)

View File

@ -1,32 +1,32 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {UserProjectFactory} from '../../factories/users_project'
import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
describe('Project View Project', () => {
describe('List View List', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
it('Should be an empty project', () => {
cy.visit('/projects/1')
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/projects/1/list')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
.should('contain', '/lists/1/list')
cy.get('.list-title')
.should('contain', 'First List')
cy.get('.list-title-dropdown')
.should('exist')
cy.get('p')
.contains('This project is currently empty.')
.contains('This list is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/projects/1')
cy.visit('/lists/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
@ -36,9 +36,9 @@ describe('Project View Project', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -49,32 +49,33 @@ describe('Project View Project', () => {
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a project which is shared read only', () => {
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserProjectFactory.create(1, {
project_id: 2,
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const projects = ProjectFactory.create(2, {
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/projects/${projects[1].id}/`)
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.project-title-wrapper .icon')
cy.get('.list-title-wrapper .icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a project in the navigation and not in the list view', () => {
const projects = ProjectFactory.create(1, {
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
project_id: projects[0].id,
list_id: lists[0].id,
})
cy.visit(`/projects/${projects[0].id}/`)
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
@ -86,9 +87,9 @@ describe('Project View Project', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks')
.should('contain', tasks[1].title)

View File

@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
describe('Project View Table', () => {
describe('List View Table', () => {
createFakeUserAndLogin()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.should('exist')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table .filter-container .items .button')
cy.get('.list-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.project-table table.table th')
cy.get('.list-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.project-table table.table th')
cy.get('.list-table table.table th')
.contains('Done')
.should('not.exist')
})
@ -40,11 +40,11 @@ describe('Project View Table', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.contains(tasks[0].title)
.click()

View File

@ -0,0 +1,122 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
describe('Lists', () => {
createFakeUserAndLogin()
let lists
prepareLists((newLists) => (lists = newLists))
it('Should create a new list', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New list')
.click()
cy.url()
.should('contain', '/lists/new/1')
cy.get('.card-header-title')
.contains('New list')
cy.get('input.input')
.type('New List')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
.should('contain', 'Success')
cy.url()
.should('contain', '/lists/')
cy.get('.list-title')
.should('contain', 'New List')
})
it('Should redirect to a specific list view after visited', () => {
cy.visit('/lists/1/kanban')
cy.url()
.should('contain', '/lists/1/kanban')
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/kanban')
})
it('Should rename the list in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.list-title-dropdown')
.click()
cy.get('.list-title-dropdown .dropdown-menu .dropdown-item')
.contains('Archive')
.click()
cy.get('.modal-content')
.should('contain.text', 'Archive this list')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', lists[0].title)
cy.get('main.app-content')
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
})
})

View File

@ -0,0 +1,145 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
describe('Namepaces', () => {
createFakeUserAndLogin()
let namespaces
beforeEach(() => {
namespaces = NamespaceFactory.create(1)
ListFactory.create(1)
})
it('Should be all there', () => {
cy.visit('/namespaces')
cy.get('[data-cy="namespace-title"]')
.should('contain', namespaces[0].title)
})
it('Should create a new Namespace', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('[data-cy="new-namespace"]')
.should('contain', 'New namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'New namespace')
cy.get('input.input')
.type(newNamespaceTitle)
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container')
.should('contain', newNamespaceTitle)
cy.url()
.should('contain', '/namespaces')
})
it('Should rename the namespace all places', () => {
const newNamespaces = NamespaceFactory.create(5)
const newNamespaceName = 'New namespace name'
cy.visit('/namespaces')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.url()
.should('contain', '/settings/edit')
cy.get('#namespacetext')
.invoke('val')
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
it('Should remove a namespace when deleting it', () => {
const newNamespaces = NamespaceFactory.create(5)
cy.visit('/')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived lists & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ListFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ListFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/namespaces')
cy.get('.namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('be.checked')
cy.get('.namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/namespaces')
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
cy.get('.namespace')
.should('not.contain', 'Archived')
})
})

View File

@ -0,0 +1,19 @@
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createLists() {
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
return lists
}
export function prepareLists(setLists = (...args: any[]) => {}) {
beforeEach(() => {
const lists = createLists()
setLists(lists)
})
}

View File

@ -1,18 +1,18 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {UserProjectFactory} from '../../factories/users_project'
import {BucketFactory} from '../../factories/bucket'
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
describe('Editor', () => {
createFakeUserAndLogin()
beforeEach(() => {
ProjectFactory.create(1)
BucketFactory.create(1)
NamespaceFactory.create(1)
ListFactory.create(1)
TaskFactory.truncate()
UserProjectFactory.truncate()
UserListFactory.truncate()
})
it('Has a preview with checkable checkboxes', () => {
@ -24,7 +24,6 @@ describe('Editor', () => {
* [ ] Checklist
* [x] Checklist checked
`,
bucket_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)

View File

@ -8,20 +8,20 @@ describe('The Menu', () => {
})
it('Is visible by default on desktop', () => {
cy.get('.menu-container')
cy.get('.namespace-container')
.should('have.class', 'is-active')
})
it('Can be hidden on desktop', () => {
cy.get('button.menu-show-button:visible')
.click()
cy.get('.menu-container')
cy.get('.namespace-container')
.should('not.have.class', 'is-active')
})
it('Is hidden by default on mobile', () => {
cy.viewport('iphone-8')
cy.get('.menu-container')
cy.get('.namespace-container')
.should('not.have.class', 'is-active')
})
@ -29,7 +29,7 @@ describe('The Menu', () => {
cy.viewport('iphone-8')
cy.get('button.menu-show-button:visible')
.click()
cy.get('.menu-container')
cy.get('.namespace-container')
.should('have.class', 'is-active')
})
})

View File

@ -1,17 +0,0 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
export function createProjects() {
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)
})
}

View File

@ -1,50 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Project History', () => {
createFakeUserAndLogin()
prepareProjects()
it('should show a project history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadProject')
// cy.visit('/')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="projectCardGrid"]')
.should('not.contain', projects[0].title)
.should('contain', projects[1].title)
.should('contain', projects[2].title)
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
})
})

View File

@ -1,171 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Projects', () => {
createFakeUserAndLogin()
let projects
prepareProjects((newProjects) => (projects = newProjects))
it('Should create a new project', () => {
cy.visit('/projects')
cy.get('.project-header [data-cy=new-project]')
.click()
cy.url()
.should('contain', '/projects/new')
cy.get('.card-header-title')
.contains('New project')
cy.get('input[name=projectTitle]')
.type('New Project')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
.should('contain', 'Success')
cy.url()
.should('contain', '/projects/')
cy.get('.project-title')
.should('contain', 'New Project')
})
it('Should redirect to a specific project view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
cy.visit('/projects/1/kanban')
cy.url()
.should('contain', '/projects/1/kanban')
cy.wait('@loadBuckets')
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/kanban')
})
it('Should rename the project in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
const newProjectName = 'New project name'
cy.visit('/projects/1')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newProjectName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.project-title')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.get('.menu-container .menu-list li:first-child')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.visit('/')
cy.get('.project-grid')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
})
it('Should remove a project when deleting it', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a project', () => {
cy.visit(`/projects/${projects[0].id}`)
cy.get('.project-title-dropdown')
.click()
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
.contains('Archive')
.click()
cy.get('.modal-content')
.should('contain.text', 'Archive this project')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.menu-container .menu-list')
.should('not.contain', projects[0].title)
cy.get('main.app-content')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
})
it('Should show all projects on the projects page', () => {
const projects = ProjectFactory.create(10)
cy.visit('/projects')
projects.forEach(p => {
cy.get('[data-cy="projects-list"]')
.should('contain', p.title)
})
})
it('Should not show archived projects if the filter is not checked', () => {
ProjectFactory.create(1, {
id: 2,
}, false)
ProjectFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/projects')
cy.get('.project-grid')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('be.checked')
cy.get('.project-grid')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/projects')
cy.get('[data-cy="show-archived-check"] input')
.should('not.be.checked')
cy.get('.project-grid')
.should('not.contain', 'Archived')
})
})

View File

@ -1,22 +1,22 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
describe('Link shares', () => {
it('Can view a link share', () => {
const projects = ProjectFactory.create(1)
const lists = ListFactory.create(1)
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
list_id: lists[0].id
})
const linkShares = LinkShareFactory.create(1, {
project_id: projects[0].id,
list_id: lists[0].id,
right: 0,
})
cy.visit(`/share/${linkShares[0].hash}/auth`)
cy.get('h1.title')
.should('contain', projects[0].title)
.should('contain', lists[0].title)
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
cy.get('.tasks')

View File

@ -1,15 +1,17 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
NamespaceFactory.create(1)
const list = ListFactory.create()[0]
BucketFactory.create(1, {
project_id: project.id,
list_id: list.id,
})
const tasks = []
let dueDate = startDueDate
@ -18,7 +20,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
tasks.push({
id: i + 1,
project_id: project.id,
list_id: list.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
@ -29,7 +31,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
})
}
seed(TaskFactory.table, tasks)
return {tasks, project}
return {tasks, list}
}
describe('Home Page Task Overview', () => {
@ -71,7 +73,7 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
@ -88,7 +90,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
@ -111,10 +113,10 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default project at the bottom', () => {
const {project} = seedTasks(40)
it('Should show a task without a due date added via default list at the bottom', () => {
const {list} = seedTasks(40)
updateUserSettings({
default_project_id: project.id,
default_list_id: list.id,
overdue_tasks_reminders_time: '9:00',
})
@ -129,22 +131,23 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new project when there are no tasks', () => {
it('Should show the cta buttons for new list when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
.should('contain.text', 'You can create a new list for your new tasks:')
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new project when there are tasks', () => {
it('Should not show the cta buttons for new list when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
.should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
})

View File

@ -1,10 +1,11 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {UserProjectFactory} from '../../factories/users_project'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
@ -46,21 +47,23 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
createFakeUserAndLogin()
let projects
let namespaces
let lists
let buckets
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
list_id: lists[0].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
UserListFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -71,11 +74,11 @@ describe('Task', () => {
.should('contain', 'New Task')
})
it('Inserts new tasks at the top of the project', () => {
it('Inserts new tasks at the top of the list', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.project-is-empty-notice')
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
@ -92,8 +95,8 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.tasks .task .fancycheckbox')
cy.visit('/lists/1/list')
cy.get('.tasks .task .fancycheckbox label.check')
.first()
.click()
cy.get('.global-notification')
@ -103,11 +106,11 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .favorite')
.first()
.click()
cy.get('.menu-container')
cy.get('.menu.namespaces-lists')
.should('contain', 'Favorites')
})
@ -130,7 +133,8 @@ describe('Task', () => {
cy.get('.task-view h1.title.task-id')
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', projects[0].title)
.should('contain', namespaces[0].title)
.should('contain', lists[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created')
@ -175,21 +179,21 @@ describe('Task', () => {
.should('contain', 'Mark as undone')
})
it('Shows a task identifier since the project has one', () => {
const projects = ProjectFactory.create(1, {
it('Shows a task identifier since the list has one', () => {
const lists = ListFactory.create(1, {
id: 1,
identifier: 'TEST',
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
index: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id')
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
})
it('Can edit the description', () => {
@ -232,14 +236,14 @@ describe('Task', () => {
.should('contain', 'Success')
})
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}'
list_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -247,7 +251,7 @@ describe('Task', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -256,7 +260,8 @@ describe('Task', () => {
.click()
cy.get('.task-view h6.subtitle')
.should('contain', projects[1].title)
.should('contain', namespaces[0].title)
.should('contain', lists[1].title)
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -264,7 +269,7 @@ describe('Task', () => {
it('Can delete a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -281,17 +286,17 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.url()
.should('contain', `/projects/${tasks[0].project_id}/`)
.should('contain', `/lists/${tasks[0].list_id}/`)
})
it('Can add an assignee to a task', () => {
const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
@ -316,10 +321,10 @@ describe('Task', () => {
const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
TaskAssigneeFactory.create(1, {
@ -342,7 +347,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
LabelFactory.truncate()
const newLabelText = 'some new label'
@ -370,7 +375,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
@ -383,13 +388,13 @@ describe('Task', () => {
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -407,7 +412,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, {
@ -522,13 +527,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

@ -1,12 +1,11 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
"compilerOptions": {
"baseUrl": ".",
"isolatedModules": false,
"target": "ES2015",
"lib": ["ESNext", "dom"],
"types": ["cypress"],
"ignoreDeprecations": "5.0"
"types": ["cypress"]
}
}

View File

@ -1,14 +1,12 @@
import {UserFactory} from '../../factories/user'
const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login')
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.wait('@login')
cy.wait(5000) // It can take waaaayy too long to log the user in
cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.')
}

View File

@ -1,5 +1,5 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {createProjects} from '../project/prepareProjects'
import {createLists} from '../list/prepareLists'
function logout() {
cy.get('.navbar .username-dropdown-trigger')
@ -26,21 +26,21 @@ describe('Log out', () => {
})
})
it.skip('Should clear the project history after logging the user out', () => {
const projects = createProjects()
cy.visit(`/projects/${projects[0].id}`)
it.skip('Should clear the list history after logging the user out', () => {
const lists = createLists()
cy.visit(`/lists/${lists[0].id}`)
.then(() => {
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
expect(localStorage.getItem('listHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('projectHistory')).to.eq(null)
expect(localStorage.getItem('listHistory')).to.eq(null)
})
})
})

View File

@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: 1,
list_id: 1,
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),

View File

@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
return {
id: '{increment}',
hash: faker.random.word(32),
project_id: 1,
list_id: 1,
right: 0,
sharing_type: 0,
shared_by_id: 1,

19
cypress/factories/list.ts Normal file
View File

@ -0,0 +1,19 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class ListFactory extends Factory {
static table = 'lists'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
namespace_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

@ -1,8 +1,8 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export class ProjectFactory extends Factory {
static table = 'projects'
export class NamespaceFactory extends Factory {
static table = 'namespaces'
static factory() {
const now = new Date()
@ -15,4 +15,4 @@ export class ProjectFactory extends Factory {
updated: now.toISOString(),
}
}
}
}

View File

@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
id: '{increment}',
title: faker.lorem.words(3),
done: false,
project_id: 1,
list_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',

View File

@ -1,14 +1,14 @@
import {Factory} from '../support/factory'
export class UserProjectFactory extends Factory {
static table = 'users_projects'
export class UserListFactory extends Factory {
static table = 'users_lists'
static factory() {
const now = new Date()
return {
id: '{increment}',
project_id: 1,
list_id: 1,
user_id: 1,
right: 0,
created: now.toISOString(),

1
docker/injector.sh Executable file → Normal file
View File

@ -11,6 +11,5 @@ VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
date -uIseconds | xargs echo 'info: started at'

0
docker/ipv6-disable.sh Executable file → Normal file
View File

View File

@ -4,6 +4,7 @@
pid /tmp/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
multi_accept on;

View File

@ -28,20 +28,6 @@ server {
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404;
}
# Disable caching for sw
location = /sw.js {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# Disable caching for webmanifest
location = /manifest.webmanifest {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# favicon.ico
location = /favicon.ico {

View File

@ -30,21 +30,21 @@ A basic service can look like this:
```javascript
import AbstractService from './abstractService'
import ProjectModel from '../models/project'
import ListModel from '../models/list'
export default class ProjectService extends AbstractService {
export default class ListService extends AbstractService {
constructor() {
super({
getAll: '/projects',
get: '/projects/{id}',
create: '/namespaces/{namespaceID}/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
getAll: '/lists',
get: '/lists/{id}',
create: '/namespaces/{namespaceID}/lists',
update: '/lists/{id}',
delete: '/lists/{id}',
})
}
modelFactory(data) {
return new ProjectModel(data)
return new ListModel(data)
}
}
```
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
export default class ProjectModel extends AbstractModel {
export default class ListModel extends AbstractModel {
constructor(data) {
// The constructor of AbstractModel handles all the default parsing.

10
env.d.ts vendored
View File

@ -3,16 +3,6 @@
/// <reference types="cypress" />
/// <reference types="@histoire/plugin-vue/components" />
declare module 'postcss-focus-within/browser' {
import focusWithinInit from 'postcss-focus-within/browser'
export default focusWithinInit
}
declare module 'css-has-pseudo/browser' {
import cssHasPseudo from 'css-has-pseudo/browser'
export default cssHasPseudo
}
interface ImportMetaEnv {
readonly VITE_IS_ONLINE: boolean
}

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1685498995,
"narHash": "sha256-rdyjnkq87tJp+T2Bm1OD/9NXKSsh/vLlPeqCc/mm7qs=",
"lastModified": 1664753041,
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9cfaa8a1a00830d17487cb60a19bb86f96f09b27",
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github"
},
"original": {

View File

@ -28,7 +28,7 @@ export default defineConfig({
// light: './img/light.png',
// dark: './img/dark.png',
// },
logoHref: 'https://vikunja.io',
// logoHref: 'https://acme.com',
// favicon: './favicon.ico',
},
})

View File

@ -27,9 +27,6 @@
// our sentry instance to notify us of potential problems.
window.SENTRY_ENABLED = false
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
// This setting might change in the future or be removed completely.
window.PROJECT_INFINITE_NESTING_ENABLED = false
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.6.0",
"packageManager": "pnpm@7.27.0",
"keywords": [
"todo",
"productivity",
@ -45,106 +45,102 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/vue-fontawesome": "3.0.3",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.4",
"@intlify/unplugin-vue-i18n": "0.11.0",
"@kyvg/vue3-notification": "2.9.1",
"@sentry/tracing": "7.53.1",
"@sentry/vue": "7.53.1",
"@vueuse/core": "10.1.2",
"axios": "1.4.0",
"blurhash": "2.0.5",
"@intlify/unplugin-vue-i18n": "0.8.2",
"@kyvg/vue3-notification": "2.9.0",
"@sentry/tracing": "7.37.2",
"@sentry/vue": "7.37.2",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.12.0",
"axios": "1.3.3",
"blurhash": "2.0.4",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.13",
"date-fns": "2.30.0",
"codemirror": "5.65.11",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dompurify": "3.0.3",
"dompurify": "3.0.0",
"easymde": "2.18.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
"floating-vue": "2.0.0-beta.20",
"highlight.js": "11.8.0",
"focus-within": "3.0.2",
"highlight.js": "11.7.0",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lodash.debounce": "4.0.8",
"marked": "5.0.4",
"pinia": "2.0.36",
"marked": "4.2.12",
"pinia": "2.0.30",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "1.1.2",
"ufo": "1.0.1",
"vue": "3.2.47",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
"vue-flatpickr-component": "11.0.2",
"vue-i18n": "9.2.2",
"vue-router": "4.2.2",
"workbox-precaching": "6.6.1",
"vue-router": "4.1.6",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.3",
"@cypress/vite-dev-server": "5.0.5",
"@cypress/vue": "5.0.5",
"@faker-js/faker": "8.0.2",
"@histoire/plugin-screenshot": "0.16.1",
"@histoire/plugin-vue": "0.16.1",
"@rushstack/eslint-patch": "1.3.0",
"@tsconfig/node18": "2.0.1",
"@cypress/vite-dev-server": "5.0.2",
"@cypress/vue": "5.0.4",
"@faker-js/faker": "7.6.0",
"@histoire/plugin-screenshot": "0.15.3",
"@histoire/plugin-vue": "0.15.3",
"@rushstack/eslint-patch": "1.2.0",
"@types/codemirror": "5.60.7",
"@types/dompurify": "3.0.2",
"@types/dompurify": "2.4.0",
"@types/flexsearch": "0.7.3",
"@types/is-touch-device": "1.0.0",
"@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "5.0.0",
"@types/node": "18.16.16",
"@types/marked": "4.0.8",
"@types/node": "18.13.0",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.1",
"@typescript-eslint/eslint-plugin": "5.59.8",
"@typescript-eslint/parser": "5.59.8",
"@vitejs/plugin-legacy": "4.0.4",
"@vitejs/plugin-vue": "4.2.3",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/test-utils": "2.3.2",
"@vue/tsconfig": "0.4.0",
"autoprefixer": "10.4.14",
"@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.52.0",
"@vitejs/plugin-legacy": "4.0.1",
"@vitejs/plugin-vue": "4.0.0",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.2.10",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.13",
"browserslist": "4.21.5",
"caniuse-lite": "1.0.30001489",
"css-has-pseudo": "5.0.2",
"csstype": "3.1.2",
"cypress": "12.13.0",
"esbuild": "0.17.19",
"eslint": "8.41.0",
"eslint-plugin-vue": "9.13.0",
"happy-dom": "9.20.1",
"histoire": "0.16.1",
"postcss": "8.4.24",
"caniuse-lite": "1.0.30001451",
"csstype": "3.1.1",
"cypress": "12.5.1",
"esbuild": "0.17.8",
"eslint": "8.34.0",
"eslint-plugin-vue": "9.9.0",
"happy-dom": "8.2.6",
"histoire": "0.15.3",
"netlify-cli": "12.12.0",
"postcss": "8.4.21",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "3.0.1",
"postcss-focus-within": "7.0.2",
"postcss-preset-env": "8.4.1",
"rollup": "3.23.0",
"postcss-preset-env": "8.0.1",
"rollup": "3.15.0",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.62.1",
"start-server-and-test": "2.0.0",
"typescript": "5.0.4",
"vite": "4.3.9",
"vite-plugin-inject-preload": "1.3.1",
"vite-plugin-pwa": "0.15.2",
"sass": "1.58.1",
"start-server-and-test": "1.15.4",
"typescript": "4.9.5",
"vite": "4.1.1",
"vite-plugin-inject-preload": "1.3.0",
"vite-plugin-pwa": "0.14.4",
"vite-svg-loader": "4.0.0",
"vitest": "0.31.2",
"vue-tsc": "1.6.5",
"vitest": "0.28.5",
"vue-tsc": "1.1.0",
"wait-on": "7.0.1",
"workbox-cli": "6.6.1"
},
"pnpm": {
"patchedDependencies": {
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
}
"workbox-cli": "6.5.4"
}
}

View File

@ -1,16 +0,0 @@
diff --git a/index.d.ts b/index.d.ts
deleted file mode 100644
index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000
diff --git a/package.json b/package.json
index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,6 @@
},
"main": "dist/flexsearch.bundle.js",
"browser": "dist/flexsearch.bundle.js",
- "module": "dist/module/index.js",
- "types": "./index.d.ts",
"preferGlobal": false,
"repository": {
"type": "git",

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
],
"packageRules": [
{
"matchPackageNames": ["happy-dom"],
"matchPackageNames": ["netlify-cli", "happy-dom"],
"extends": ["schedule:weekly"]
},
{

View File

@ -33,9 +33,9 @@ const promiseExec = cmd => {
}
(async function () {
let stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify link --id ${siteId}`)
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
console.log(stdout)
stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify deploy --alias ${alias}`)
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
console.log(stdout)
const data = await fetch(prIssueCommentsUrl).then(response => response.json())

View File

@ -1 +1 @@
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs
57af69409e66bc87f4f2fc5822dd8d3c2eb47c601f81af1ac4a56f3e2d80837b1a2de06f4ff57695ec379b7c15b881e3 ./scripts/deploy-preview-netlify.mjs

Binary file not shown.

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z" stroke-dasharray="60"></path>
<polyline points="1 9 7 14 15 4" stroke-dasharray="22" stroke-dashoffset="66"></polyline>
</svg>

Before

Width:  |  Height:  |  Size: 420 B

View File

@ -1,54 +0,0 @@
<template>
<div class="base-checkbox" v-cy="'checkbox'">
<input
type="checkbox"
:id="checkboxId"
class="is-sr-only"
:checked="modelValue"
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
:disabled="disabled || undefined"
/>
<slot name="label" :checkboxId="checkboxId">
<label :for="checkboxId" class="base-checkbox__label">
<slot/>
</label>
</slot>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {createRandomID} from '@/helpers/randomId'
defineProps({
modelValue: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const checkboxId = ref(`fancycheckbox_${createRandomID()}`)
</script>
<style lang="scss" scoped>
.base-checkbox__label {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
display: inline-flex;
}
.base-checkbox:has(input:disabled) .base-checkbox__label {
cursor:not-allowed;
pointer-events: none;
}
</style>

View File

@ -32,7 +32,7 @@ import {computed, ref} from 'vue'
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
const props = defineProps({
/** Whether the Expandable is open or not */
/** Wheather the Expandable is open or not */
open: {
type: Boolean,
default: false,

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import datemathHelp from './datemathHelp.vue'
</script>
<template>
<Story>
<Variant title="Default">
<datemathHelp />
</Variant>
</Story>
</template>

View File

@ -1,8 +1,7 @@
<template>
<card
class="has-no-shadow how-it-works-modal"
:title="$t('input.datemathHelp.title')"
>
:title="$t('input.datemathHelp.title')">
<p>
{{ $t('input.datemathHelp.intro') }}
</p>
@ -28,11 +27,11 @@
</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
</ul>
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
<table class="table">
<tbody>
<tr>
@ -70,7 +69,7 @@
</tbody>
</table>
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
<table class="table">
<tbody>
<tr>
@ -101,7 +100,7 @@
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
<strong>{{ exampleDate }}</strong>
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
</tr>
@ -111,15 +110,13 @@
</template>
<script lang="ts" setup>
import {formatDateShort} from '@/helpers/time/formatDate'
import {formatDate} from '@/helpers/time/formatDate'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = formatDateShort(new Date())
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
</script>
<style scoped lang="scss">
// FIXME: Remove style overwrites
.how-it-works-modal {
font-size: 1rem;
}

View File

@ -4,11 +4,8 @@ import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
import {MILLISECONDS_A_HOUR} from '@/constants/date'
const now = useNow({
interval: MILLISECONDS_A_HOUR,
})
const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
</script>

View File

@ -1,107 +0,0 @@
<template>
<draggable
v-model="availableProjects"
animation="100"
ghostClass="ghost"
group="projects"
@start="() => drag = true"
@end="saveProjectPosition"
handle=".handle"
tag="menu"
item-key="id"
:disabled="!canEditOrder"
:component-data="{
type: 'transition-group',
name: !drag ? 'flip-list' : null,
class: [
'menu-list can-be-hidden',
{ 'dragging-disabled': !canEditOrder }
]
}"
>
<template #item="{element: project}">
<ProjectsNavigationItem
:project="project"
:is-loading="projectUpdating[project.id]"
:can-collapse="canCollapse"
:level="level"
:data-project-id="project.id"
/>
</template>
</draggable>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import type {IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects'
const props = defineProps<{
modelValue?: IProject[],
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits<{
(e: 'update:modelValue', projects: IProject[]): void
}>()
const drag = ref(false)
const projectStore = useProjectStore()
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
// Hence, we'll clone the prop and work on the clone.
const availableProjects = ref<IProject[]>([])
watch(
() => props.modelValue,
projects => {
availableProjects.value = projects || []
},
{immediate: true},
)
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
async function saveProjectPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
const projectsActive = availableProjects.value
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
// To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
const projectId = parseInt(e.item.dataset.projectId)
const project = projectStore.projects[projectId]
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
const projectBefore = projectsActive[newIndex - 1] ?? null
const projectAfter = projectsActive[newIndex + 1] ?? null
projectUpdating.value[project.id] = true
const position = calculateItemPosition(
projectBefore !== null ? projectBefore.position : null,
projectAfter !== null ? projectAfter.position : null,
)
try {
// create a copy of the project in order to not violate pinia manipulation
await projectStore.updateProject({
...project,
position,
parentProjectId,
})
emit('update:modelValue', availableProjects.value)
} finally {
projectUpdating.value[project.id] = false
}
}
</script>

View File

@ -1,156 +0,0 @@
<template>
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': isLoading}"
>
<div>
<BaseButton
v-if="canCollapse && childProjects?.length > 0"
@click="childProjectsOpen = !childProjectsOpen"
class="collapse-project-button"
>
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
</BaseButton>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject?.id === project.id}"
>
<span
v-if="!canCollapse || childProjects?.length === 0"
class="collapse-project-button-placeholder"
></span>
<div class="color-bubble-handle-wrapper">
<ColorBubble
v-if="project.hexColor !== ''"
:color="project.hexColor"
/>
<span
class="icon menu-item-icon handle lines-handle"
:class="{'has-color-bubble': project.hexColor !== ''}"
>
<icon icon="grip-lines"/>
</span>
</div>
<span class="list-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
<BaseButton
v-if="project.id > 0"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click="projectStore.toggleProjectFavorite(project)"
>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<ProjectSettingsDropdown
v-if="project.id > 0"
class="menu-list-dropdown"
:project="project"
:level="level"
>
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</ProjectSettingsDropdown>
<span class="list-setting-spacer" v-else></span>
</div>
<ProjectsNavigation
v-if="canNestDeeper && childProjectsOpen && canCollapse"
:model-value="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useProjectStore} from '@/stores/projects'
import {useBaseStore} from '@/stores/base'
import type {IProject} from '@/modelTypes/IProject'
import BaseButton from '@/components/base/BaseButton.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import ColorBubble from '@/components/misc/colorBubble.vue'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
const props = withDefaults(defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>(), {
level: 0,
})
const projectStore = useProjectStore()
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const childProjectsOpen = ref(true)
const childProjects = computed(() => {
if (!canNestDeeper.value) {
return []
}
return projectStore.getChildProjects(props.project.id)
.sort((a, b) => a.position - b.position)
})
const canNestDeeper = computed(() => canNestProjectDeeper(props.level))
</script>
<style lang="scss" scoped>
.list-setting-spacer {
width: 5rem;
flex-shrink: 0;
}
.project-is-collapsed {
transform: rotate(-90deg);
}
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover,
&.is-favorite {
opacity: 1;
color: var(--warning);
}
}
.list-menu:hover > div > .favorite {
opacity: 1;
}
.list-menu:hover > div > a > .color-bubble-handle-wrapper > .color-bubble {
opacity: 0;
}
.color-bubble-handle-wrapper {
position: relative;
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: flex-start;
margin-right: .25rem;
.color-bubble, .icon {
transition: all $transition;
position: absolute;
width: 12px;
margin: 0 !important;
padding: 0 !important;
}
}
</style>

View File

@ -1,51 +1,67 @@
<template>
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation"
class="navbar d-print-none">
<router-link :to="{ name: 'home' }" class="logo-link">
<Logo width="164" height="48" />
<header
:class="{'has-background': background, 'menu-active': menuActive}"
aria-label="main navigation"
class="navbar d-print-none"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button" />
<MenuButton class="menu-button"/>
<div v-if="currentProject?.id" class="project-title-wrapper">
<h1 class="project-title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
<icon icon="circle-info" />
<div
v-if="currentList.id"
class="list-title-wrapper"
>
<h1 class="list-title">{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="list-title-button">
<icon icon="circle-info"/>
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown" :project="currentProject">
<template #trigger="{ toggleOpen }">
<BaseButton class="project-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon" />
<list-settings-dropdown
v-if="canWriteCurrentList && currentList.id !== -1"
class="list-title-dropdown"
:list="currentList"
>
<template #trigger="{toggleOpen}">
<BaseButton class="list-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</project-settings-dropdown>
</list-settings-dropdown>
</div>
<div class="navbar-end">
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')">
<icon icon="search" />
<BaseButton
@click="openQuickActions"
class="trigger-button"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
<Notifications />
<dropdown>
<template #trigger="{ toggleOpen, open }">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" />
<template #trigger="{toggleOpen, open}">
<BaseButton
class="username-dropdown-trigger"
@click="toggleOpen"
variant="secondary"
:shadow="false"
>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small" :style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)',
}">
<icon icon="chevron-down" />
<icon icon="chevron-down"/>
</span>
</BaseButton>
</template>
<dropdown-item :to="{ name: 'user.settings' }">
<dropdown-item :to="{name: 'user.settings'}">
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
@ -57,7 +73,7 @@
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item :to="{ name: 'about' }">
<dropdown-item :to="{name: 'about'}">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
@ -69,11 +85,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {computed} from 'vue'
import { RIGHTS as Rights } from '@/constants/rights'
import {RIGHTS as Rights} from '@/constants/rights'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
@ -81,16 +97,16 @@ import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import { getProjectTitle } from '@/helpers/getProjectTitle'
import {getListTitle} from '@/helpers/getListTitle'
import { useBaseStore } from '@/stores/base'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
@ -150,7 +166,7 @@ $user-dropdown-width-mobile: 5rem;
.logo-link {
display: none;
@media screen and (min-width: $tablet) {
align-self: stretch;
display: flex;
@ -169,12 +185,12 @@ $user-dropdown-width-mobile: 5rem;
}
}
.project-title-wrapper {
.list-title-wrapper {
margin-inline: auto;
display: flex;
align-items: center;
// this makes the truncated text of the project title work
// this makes the truncated text of the list title work
// inside the flexbox parent
min-width: 0;
@ -183,7 +199,7 @@ $user-dropdown-width-mobile: 5rem;
}
}
.project-title {
.list-title {
font-size: 1rem;
// We need the following for overflowing ellipsis to work
text-overflow: ellipsis;
@ -195,15 +211,15 @@ $user-dropdown-width-mobile: 5rem;
}
}
.project-title-dropdown {
.list-title-dropdown {
align-self: stretch;
.project-title-button {
.list-title-button {
flex-grow: 1;
}
}
.project-title-button {
.list-title-button {
align-self: stretch;
min-width: var(--navbar-button-min-width);
display: flex;
@ -219,7 +235,7 @@ $user-dropdown-width-mobile: 5rem;
display: flex;
align-items: stretch;
>* {
> * {
min-width: var(--navbar-button-min-width);
}
}

View File

@ -33,7 +33,7 @@
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
@ -69,7 +69,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
@ -88,25 +87,26 @@ function showKeyboardShortcuts() {
const route = useRoute()
// FIXME: this is really error prone
// Reset the current project highlight in menu if the current route is not project related.
// Reset the current list highlight in menu if the current route is not list related.
watch(() => route.name as string, (routeName) => {
if (
routeName &&
(
[
'home',
'namespace.edit',
'teams.index',
'teams.edit',
'tasks.range',
'labels.index',
'migrate.start',
'migrate.wunderlist',
'projects.index',
'namespaces.index',
].includes(routeName) ||
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentProject({project: null})
baseStore.handleSetCurrentList({list: null})
}
})
@ -116,9 +116,6 @@ useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const projectStore = useProjectStore()
projectStore.loadProjects()
</script>
<style lang="scss" scoped>
@ -159,8 +156,6 @@ projectStore.loadProjects()
z-index: 10;
position: relative;
padding: 1.5rem 0.5rem 1rem;
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
transition: margin-left $transition-duration;
@media screen and (max-width: $tablet) {
margin-left: 0;

View File

@ -9,9 +9,9 @@
<Logo class="logo" v-if="logoVisible"/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<router-view/>
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
</script>

View File

@ -1,10 +1,10 @@
<template>
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
<aside :class="{'is-active': menuActive}" class="namespace-container">
<nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/>
</router-link>
<menu class="menu-list other-menu-items">
<ul class="menu-list">
<li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<span class="menu-item-icon icon">
@ -22,11 +22,11 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
<span class="menu-item-icon icon">
<icon icon="layer-group"/>
</span>
{{ $t('project.projects') }}
{{ $t('namespace.title') }}
</router-link>
</li>
<li>
@ -45,51 +45,237 @@
{{ $t('team.title') }}
</router-link>
</li>
</menu>
</ul>
</nav>
<Loading
v-if="projectStore.isLoading"
variant="small"
/>
<template v-else>
<nav class="menu" v-if="favoriteProjects">
<ProjectsNavigation :model-value="favoriteProjects" :can-edit-order="false" :can-collapse="false"/>
</nav>
<nav class="menu">
<ProjectsNavigation
:model-value="projects"
:can-edit-order="true"
:can-collapse="true"
:level="1"
/>
</nav>
</template>
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
<BaseButton
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="namespaceTitles[nk]"
>
<ColorBubble
v-if="n.hexColor !== ''"
:color="n.hexColor"
class="mr-1"
/>
<span class="name">{{ namespaceTitles[nk] }}</span>
<div
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
>
<icon icon="chevron-down"/>
</div>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }})
</span>
</BaseButton>
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
</div>
<!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace
-->
<draggable
v-if="listsVisible[n.id] ?? true"
v-bind="dragOptions"
:modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)"
group="namespace-lists"
@start="() => drag = true"
@end="saveListPosition"
handle=".handle"
:disabled="n.id < 0 || undefined"
tag="ul"
item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{
type: 'transition-group',
name: !drag ? 'flip-list' : null,
class: [
'menu-list can-be-hidden',
{ 'dragging-disabled': n.id < 0 }
]
}"
>
<template #item="{element: l}">
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}"
>
<span class="icon menu-item-icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
v-if="l.hexColor !== ''"
:color="l.hexColor"
class="mr-1"
/>
<span class="list-menu-title">{{ getListTitle(l) }}</span>
</BaseButton>
<BaseButton
class="favorite"
:class="{'is-favorite': l.isFavorite}"
@click="listStore.toggleListFavorite(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</list-settings-dropdown>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</draggable>
</template>
</nav>
<PoweredByLink/>
</aside>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {ref, computed, onBeforeMount} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import Loading from '@/components/misc/loading.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false)
const dragOptions = {
animation: 100,
ghostClass: 'ghost',
}
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
const projects = computed(() => projectStore.notArchivedRootProjects)
const favoriteProjects = computed(() => projectStore.favoriteProjects)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived)
})
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
return lists?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
})
const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
})
const listStore = useListStore()
function toggleLists(namespaceId: INamespace['id']) {
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
}
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => {
const namespaces = await namespaceStore.loadNamespaces()
namespaces.forEach(n => {
if (typeof listsVisible.value[n.id] === 'undefined') {
listsVisible.value[n.id] = true
}
})
})
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier.
const lists = [
...activeLists,
...namespace.lists.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({
...namespace,
lists,
})
}
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async function saveListPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const listsActive = activeLists.value[newNamespaceIndex]
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
// To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
const list = listsActive[newIndex]
const listBefore = listsActive[newIndex - 1] ?? null
const listAfter = listsActive[newIndex + 1] ?? null
listUpdating.value[list.id] = true
const position = calculateItemPosition(
listBefore !== null ? listBefore.position : null,
listAfter !== null ? listAfter.position : null,
)
try {
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
...list,
position,
namespaceId,
})
} finally {
listUpdating.value[list.id] = false
}
}
</script>
<style lang="scss" scoped>
$navbar-padding: 2rem;
$vikunja-nav-background: var(--site-background);
$vikunja-nav-color: var(--grey-700);
$vikunja-nav-selected-width: 0.4rem;
.logo {
display: block;
@ -102,8 +288,8 @@ const favoriteProjects = computed(() => projectStore.favoriteProjects)
}
}
.menu-container {
background: var(--site-background);
.namespace-container {
background: $vikunja-nav-background;
color: $vikunja-nav-color;
padding: 0 0 1rem;
transition: transform $transition-duration ease-in;
@ -114,7 +300,6 @@ const favoriteProjects = computed(() => projectStore.favoriteProjects)
transform: translateX(-100%);
overflow-x: auto;
width: $navbar-width;
margin-top: 1rem;
@media screen and (max-width: $tablet) {
top: 0;
@ -128,24 +313,235 @@ const favoriteProjects = computed(() => projectStore.favoriteProjects)
}
}
.top-menu .menu-list {
li {
font-weight: 600;
font-family: $vikunja-font;
// these are general menu styles
// should be in own components
.menu {
.menu-label,
.menu-list .list-menu-link,
.menu-list a {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
.color-bubble {
height: 12px;
flex: 0 0 12px;
}
}
.list-menu-link,
li > a {
padding-left: 2rem;
display: inline-block;
.menu-list {
li {
height: 44px;
display: flex;
align-items: center;
.icon {
padding-bottom: .25rem;
&:hover {
background: var(--white);
}
.menu-list-dropdown {
opacity: 0;
transition: $transition;
}
&:hover .menu-list-dropdown {
opacity: 1;
}
}
.menu-item-icon {
color: var(--grey-400);
}
.menu-list-dropdown-trigger {
display: flex;
padding: 0.5rem;
}
.flip-list-move {
transition: transform $transition-duration;
}
.ghost {
background: var(--grey-200);
* {
opacity: 0;
}
}
a:hover {
background: transparent;
}
.list-menu-link,
li > a {
color: $vikunja-nav-color;
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease;
border-radius: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
border-left: $vikunja-nav-selected-width solid transparent;
&:hover {
border-left: $vikunja-nav-selected-width solid var(--primary);
}
&.router-link-exact-active {
color: var(--primary);
border-left: $vikunja-nav-selected-width solid var(--primary);
}
.icon {
height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
}
&.router-link-exact-active .icon:not(.handle) {
color: var(--primary);
}
.handle {
opacity: 0;
transition: opacity $transition;
margin-right: .25rem;
cursor: grab;
}
&:hover .handle {
opacity: 1;
}
}
}
}
.menu + .menu {
.top-menu {
margin-top: math.div($navbar-padding, 2);
.menu-list {
li {
font-weight: 600;
font-family: $vikunja-font;
}
.list-menu-link,
li > a {
padding-left: 2rem;
display: inline-block;
.icon {
padding-bottom: .25rem;
}
}
}
}
.namespaces-lists {
padding-top: math.div($navbar-padding, 2);
.menu-label {
font-size: 1rem;
font-weight: 700;
font-weight: bold;
font-family: $vikunja-font;
color: $vikunja-nav-color;
font-weight: 600;
min-height: 2.5rem;
padding-top: 0;
padding-left: $navbar-padding;
overflow: hidden;
margin-bottom: 0;
flex: 1 1 auto;
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.count {
color: var(--grey-500);
margin-right: .5rem;
// align brackets with number
font-feature-settings: "case";
}
}
.favorite {
margin-left: .25rem;
transition: opacity $transition, color $transition;
opacity: 0;
&:hover,
&.is-favorite {
color: var(--warning);
}
}
.favorite.is-favorite,
.list-menu:hover .favorite {
opacity: 1;
}
.list-menu-title {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.color-bubble {
width: 14px;
height: 14px;
flex-basis: auto;
}
.is-archived {
min-width: 85px;
}
}
.namespace-title {
display: flex;
align-items: center;
justify-content: space-between;
color: $vikunja-nav-color;
padding: 0 .25rem;
.toggle-lists-icon {
svg {
transition: all $transition;
transform: rotate(90deg);
opacity: 1;
}
&.active svg {
transform: rotate(0deg);
opacity: 0;
}
}
&:hover .toggle-lists-icon svg {
opacity: 1;
}
&:not(.has-menu) .toggle-lists-icon {
padding-right: 1rem;
}
}
.list-setting-spacer {
width: 2.5rem;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedLists"
:search-results="foundLists"
:loading="listService.loading"
:multiple="true"
:placeholder="$t('list.search')"
label="title"
@search="findLists"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IList} from '@/modelTypes/IList'
import ListService from '@/services/list'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IList[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IList[]): void
}>()
const lists = ref<IList[]>([])
watchEffect(() => {
lists.value = props.modelValue
})
const selectedLists = computed({
get() {
return lists.value
},
set: (value) => {
lists.value = value
emit('update:modelValue', value)
},
})
const listService = shallowReactive(new ListService())
const foundLists = ref<IList[]>([])
async function findLists(query: string) {
if (query === '') {
foundLists.value = []
return
}
const response = await listService.getAll({}, {s: query}) as IList[]
// Filter selected items from the results
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedNamespaces"
:search-results="foundNamespaces"
:loading="namespaceService.loading"
:multiple="true"
:placeholder="$t('namespace.search')"
label="namespace"
@search="findNamespaces"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import NamespaceService from '@/services/namespace'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<INamespace[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: INamespace[]): void
}>()
const namespaces = ref<INamespace[]>([])
watchEffect(() => {
namespaces.value = props.modelValue
})
const selectedNamespaces = computed({
get() {
return namespaces.value
},
set: (value) => {
namespaces.value = value
emit('update:modelValue', value)
},
})
const namespaceService = shallowReactive(new NamespaceService())
const foundNamespaces = ref<INamespace[]>([])
async function findNamespaces(query: string) {
if (query === '') {
foundNamespaces.value = []
return
}
const response = await namespaceService.getAll({}, {s: query}) as INamespace[]
// Filter selected items from the results
foundNamespaces.value = response.filter(({id}) => !includesById(namespaces.value, id))
}
</script>

View File

@ -1,63 +0,0 @@
<template>
<multiselect
v-model="selectedProjects"
:search-results="foundProjects"
:loading="projectService.loading"
:multiple="true"
:placeholder="$t('project.search')"
label="title"
@search="findProjects"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectService from '@/services/project'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IProject[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IProject[]): void
}>()
const projects = ref<IProject[]>([])
watchEffect(() => {
projects.value = props.modelValue
})
const selectedProjects = computed({
get() {
return projects.value
},
set: (value) => {
projects.value = value
emit('update:modelValue', value)
},
})
const projectService = shallowReactive(new ProjectService())
const foundProjects = ref<IProject[]>([])
async function findProjects(query: string) {
if (query === '') {
foundProjects.value = []
return
}
const response = await projectService.getAll({}, {s: query}) as IProject[]
// Filter selected items from the results
foundProjects.value = response.filter(({id}) => !includesById(projects.value, id))
}
</script>

View File

@ -1,26 +0,0 @@
<template>
<BaseButton class="simple-button">
<slot/>
</BaseButton>
</template>
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
</script>
<style lang="scss" scoped>
.simple-button {
color: var(--text);
padding: .25rem .5rem;
transition: background-color $transition;
border-radius: $radius;
display: block;
margin: .1rem 0;
width: 100%;
text-align: left;
&:hover {
background: var(--white);
}
}
</style>

View File

@ -1,15 +1,78 @@
<template>
<div class="datepicker">
<SimpleButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
</SimpleButton>
</BaseButton>
<CustomTransition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
<DatepickerInline
v-model="date"
@update:model-value="updateData"
<BaseButton
v-if="(new Date()).getHours() < 21"
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
</span>
</BaseButton>
<flat-pickr
:config="flatPickerConfig"
class="input"
v-model="flatPickrDate"
/>
<x-button
@ -26,15 +89,19 @@
</template>
<script setup lang="ts">
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import DatepickerInline from '@/components/input/datepickerInline.vue'
import SimpleButton from '@/components/input/SimpleButton.vue'
import {formatDateShort} from '@/helpers/time/formatDate'
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
@ -58,6 +125,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const show = ref(false)
const changed = ref(false)
@ -72,6 +141,37 @@ watch(
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date) {
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
@ -112,6 +212,29 @@ function close() {
}
}, 200)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>
@ -134,6 +257,42 @@ function close() {
}
}
.datepicker__quick-select-date {
display: flex;
align-items: center;
padding: 0 .5rem;
width: 100%;
height: 2.25rem;
color: var(--text);
transition: all $transition;
&:first-child {
border-radius: $radius $radius 0 0;
}
&:hover {
background: var(--grey-100);
}
.text {
width: 100%;
font-size: .85rem;
display: flex;
justify-content: space-between;
padding-right: .25rem;
.weekday {
color: var(--text-light);
text-transform: capitalize;
}
}
.icon {
width: 2rem;
text-align: center;
}
}
.datepicker__close-button {
margin: 1rem;
width: calc(100% - 2rem);

View File

@ -1,228 +0,0 @@
<template>
<BaseButton
v-if="(new Date()).getHours() < 21"
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
</span>
</BaseButton>
<div class="flatpickr-container">
<flat-pickr
:config="flatPickerConfig"
v-model="flatPickrDate"
/>
</div>
</template>
<script lang="ts" setup>
import {ref, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import BaseButton from '@/components/base/BaseButton.vue'
import {formatDate} from '@/helpers/time/formatDate'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const changed = ref(false)
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
setDateValue,
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date | null) {
if (newValue === null) {
date.value = null
return
}
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
return
}
date.value = createDateFromString(dateString)
}
function updateData() {
changed.value = true
emit('update:modelValue', date.value)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>
.datepicker__quick-select-date {
display: flex;
align-items: center;
padding: 0 .5rem;
width: 100%;
height: 2.25rem;
color: var(--text);
transition: all $transition;
&:first-child {
border-radius: $radius $radius 0 0;
}
&:hover {
background: var(--grey-100);
}
.text {
width: 100%;
font-size: .85rem;
display: flex;
justify-content: space-between;
padding-right: .25rem;
.weekday {
color: var(--text-light);
text-transform: capitalize;
}
}
.icon {
width: 2rem;
text-align: center;
}
}
.flatpickr-container {
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
:deep(.input) {
border: none;
}
}
</style>

View File

@ -211,7 +211,7 @@ function handleInput(val: string) {
bubble(1000)
}
function bubble(timeout = 5000) {
function bubble(timeout = 500) {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
@ -286,11 +286,11 @@ function handleCheckboxClick(e: Event) {
console.debug('no index found')
return
}
const projectPrefix = text.value.substring(index, index + 1)
const listPrefix = text.value.substring(index, index + 1)
console.debug({index, projectPrefix, checked, text: text.value})
console.debug({index, listPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `)
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}

View File

@ -1,71 +0,0 @@
<script lang="ts" setup>
import {ref} from 'vue'
import {logEvent} from 'histoire/client'
import FancyCheckbox from './fancycheckbox.vue'
const isDisabled = ref<boolean | undefined>()
const isChecked = ref(false)
const isCheckedInitiallyEnabled = ref(true)
const isCheckedDisabled = ref(false)
const withoutInitialState = ref<boolean | undefined>()
</script>
<template>
<Story :layout="{ type: 'grid', width: '200px' }">
<Variant title="Default">
<FancyCheckbox
v-model="isChecked"
:disabled="isDisabled"
>
This is probably not important
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isChecked">
{{ isChecked }}
</Variant>
<Variant title="Enabled Initially">
<FancyCheckbox
:disabled="isDisabled"
v-model="isCheckedInitiallyEnabled"
>
We want you to use this option
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isCheckedInitiallyEnabled">
{{ isCheckedInitiallyEnabled }}
</Variant>
<Variant title="Disabled">
<FancyCheckbox
disabled
:modelValue="isCheckedDisabled"
@update:model-value="logEvent('Setting disabled: This should never happen', $event)"
>
You can't change this
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isCheckedDisabled" disabled>
{{ isCheckedDisabled }}
</Variant>
<Variant title="Undefined initial State">
<FancyCheckbox
v-model="withoutInitialState"
:disabled="isDisabled"
>
Not sure what the value should be
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="withoutInitialState" disabled>
{{ withoutInitialState }}
</Variant>
</Story>
</template>

View File

@ -1,42 +1,61 @@
<template>
<BaseCheckbox
class="fancycheckbox"
:class="{
'is-disabled': disabled,
'is-block': isBlock,
}"
:disabled="disabled"
:model-value="modelValue"
@update:model-value="value => emit('update:modelValue', value)"
>
<CheckboxIcon class="fancycheckbox__icon" />
<span v-if="$slots.default" class="fancycheckbox__content">
<slot/>
</span>
</BaseCheckbox>
<div :class="{'is-disabled': disabled}" class="fancycheckbox">
<input
:checked="checked"
:disabled="disabled || undefined"
:id="checkBoxId"
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
type="checkbox"
/>
<label :for="checkBoxId" class="check">
<svg height="18px" viewBox="0 0 18 18" width="18px">
<path
d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
<span>
<slot></slot>
</span>
</label>
</div>
</template>
<script setup lang="ts">
import CheckboxIcon from '@/assets/checkbox.svg?component'
import {ref, toRef, watch} from 'vue'
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import {createRandomID} from '@/helpers/randomId'
defineProps({
const checked = ref(false)
const checkBoxId = `fancycheckbox_${createRandomID()}`
const props = defineProps({
modelValue: {
type: Boolean,
required: false,
},
disabled: {
type: Boolean,
},
isBlock: {
type: Boolean,
required: false,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
newValue => {
checked.value = newValue
},
{immediate: true},
)
function updateData(newChecked: boolean) {
checked.value = newChecked
emit('update:modelValue', newChecked)
emit('change', newChecked)
}
</script>
@ -46,54 +65,75 @@ const emit = defineEmits<{
padding-right: 5px;
padding-top: 3px;
// FIXME: should be a prop
&.is-block {
display: block;
margin: .5rem .2rem;
}
}
.fancycheckbox__content {
input[type=checkbox] {
display: none;
}
.check {
cursor: pointer;
position: relative;
margin: auto;
width: 18px;
height: 18px;
-webkit-tap-highlight-color: transparent;
transform: translate3d(0, 0, 0);
}
span {
font-size: 0.8rem;
vertical-align: top;
padding-left: .5rem;
}
.fancycheckbox__icon:deep() {
svg {
position: relative;
z-index: 1;
stroke: var(--stroke-color, #c8ccd4);
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke: #c8ccd4;
stroke-width: 1.5;
transform: translate3d(0, 0, 0);
transition: all 0.2s ease;
}
path,
polyline {
transition: all 0.2s linear, color 0.2s ease;
.check:hover svg {
stroke: var(--primary);
}
.is-disabled .check:hover svg {
stroke: #c8ccd4;
}
path {
stroke-dasharray: 60;
stroke-dashoffset: 0;
}
polyline {
stroke-dasharray: 22;
stroke-dashoffset: 66;
}
input[type=checkbox]:checked + .check {
svg {
stroke: var(--primary);
}
}
.fancycheckbox:not(:has(input:disabled)):hover .fancycheckbox__icon,
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
--stroke-color: var(--primary);
}
</style>
<style lang="scss">
// Since css-has-pseudo doesn't work with deep classes,
// the following rules can't be scoped
.fancycheckbox:has(:not(input:checked)) .fancycheckbox__icon {
path {
transition-delay: 0.05s;
}
}
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
path {
stroke-dashoffset: 60;
transition: all 0.3s linear;
}
polyline {
stroke-dashoffset: 42;
transition: all 0.2s linear;
transition-delay: 0.15s;
}
}

View File

@ -0,0 +1,200 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }"
>
{{ $t('list.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }"
>
{{ $t('list.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }"
>
{{ $t('list.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }"
>
{{ $t('list.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</Message>
</CustomTransition>
<slot v-if="loadedListId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
const props = defineProps({
listId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentList
})
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the list multiple times, even when navigating away from it.
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.listId,
// loadList
async (listIdToLoad: number) => {
const listData = {id: listIdToLoad}
saveListToHistory(listData)
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listIdToLoad === loadedListId.value ||
typeof listIdToLoad === 'undefined' ||
listIdToLoad === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
loadedListId.value = props.listId
return
}
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// Set the current list to the one we're about to load so that the title is already shown at the top
loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore})
}
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -8,24 +8,24 @@
</slot>
</template>
<template v-if="isSavedFilter(project)">
<template v-if="isSavedFilter(list)">
<dropdown-item
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
</dropdown-item>
</template>
<template v-else-if="project.isArchived">
<template v-else-if="list.isArchived">
<dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
@ -33,32 +33,32 @@
</template>
<template v-else>
<dropdown-item
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image"
>
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
@ -66,21 +66,14 @@
<Subscription
class="has-no-shadow"
:is-button="false"
entity="project"
:entity-id="project.id"
:model-value="project.subscription"
entity="list"
:entity-id="list.id"
:model-value="list.subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
{{ $t('menu.createProject') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
class="has-text-danger"
>
@ -97,27 +90,26 @@ import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
project: {
type: Object as PropType<IProject>,
list: {
type: Object as PropType<IList>,
required: true,
},
level: {
type: Number,
},
})
const projectStore = useProjectStore()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.project.subscription ?? null
subscription.value = props.list.subscription ?? null
})
const configStore = useConfigStore()
@ -125,10 +117,11 @@ const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
const updatedProject = {
...props.project,
const updatedList = {
...props.list,
subscription: sub,
}
projectStore.setProject(updatedProject)
listStore.setList(updatedList)
namespaceStore.setListInNamespaceById(updatedList)
}
</script>

View File

@ -1,39 +1,39 @@
<template>
<div
class="project-card"
class="list-card"
:class="{
'has-light-text': background !== null,
'has-background': blurHashUrl !== '' || background !== null
}"
:style="{
'border-left': project.hexColor ? `0.25rem solid ${project.hexColor}` : undefined,
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
}"
>
<div
class="project-background background-fade-in"
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<span v-if="project.isArchived" class="is-archived" >{{ $t('project.archived') }}</span>
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
<div class="list-title" aria-hidden="true">{{ list.title }}</div>
<BaseButton
class="project-button"
:aria-label="project.title"
:title="project.description"
class="list-button"
:aria-label="list.title"
:title="list.description"
:to="{
name: 'project.index',
params: { projectId: project.id}
name: 'list.index',
params: { listId: list.id}
}"
/>
<BaseButton
v-if="!project.isArchived"
v-if="!list.isArchived"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click.prevent.stop="projectStore.toggleProjectFavorite(project)"
:class="{'is-favorite': list.isFavorite}"
@click.prevent.stop="listStore.toggleListFavorite(list)"
>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton>
</div>
</template>
@ -41,30 +41,30 @@
<script lang="ts" setup>
import {toRef, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import BaseButton from '@/components/base/BaseButton.vue'
import {useProjectBackground} from './useProjectBackground'
import {useProjectStore} from '@/stores/projects'
import {useListBackground} from './useListBackground'
import {useListStore} from '@/stores/lists'
const props = defineProps({
project: {
type: Object as PropType<IProject>,
list: {
type: Object as PropType<IList>,
required: true,
},
})
const {background, blurHashUrl} = useProjectBackground(toRef(props, 'project'))
const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
const projectStore = useProjectStore()
const listStore = useListStore()
</script>
<style lang="scss" scoped>
.project-card {
--project-card-padding: 1rem;
.list-card {
--list-card-padding: 1rem;
background: var(--white);
padding: var(--project-card-padding);
padding: var(--list-card-padding);
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
@ -91,14 +91,14 @@ const projectStore = useProjectStore()
}
.has-background,
.project-background {
.list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.project-background,
.project-button {
.list-background,
.list-button {
position: absolute;
top: 0;
right: 0;
@ -111,7 +111,7 @@ const projectStore = useProjectStore()
float: left;
}
.project-title {
.list-title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
@ -120,7 +120,7 @@ const projectStore = useProjectStore()
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - (var(--project-card-padding) + 1rem)); // padding & height of the "is archived" badge
max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
@ -130,11 +130,11 @@ const projectStore = useProjectStore()
-webkit-box-orient: vertical;
}
.has-light-text .project-title {
.has-light-text .list-title {
color: var(--grey-100);
}
.has-background .project-title {
.has-background .list-title {
text-shadow:
0 0 10px var(--black),
1px 1px 5px var(--grey-700),
@ -144,10 +144,10 @@ const projectStore = useProjectStore()
.favorite {
position: absolute;
top: var(--project-card-padding);
right: var(--project-card-padding);
top: var(--list-card-padding);
right: var(--list-card-padding);
transition: opacity $transition, color $transition;
opacity: 1;
opacity: 0;
&:hover {
color: var(--warning);
@ -160,14 +160,8 @@ const projectStore = useProjectStore()
}
}
@media(hover: hover) and (pointer: fine) {
.project-card .favorite {
opacity: 0;
}
.project-card:hover .favorite {
opacity: 1;
}
.list-card:hover .favorite {
opacity: 1;
}
.background-fade-in {
@ -179,4 +173,4 @@ const projectStore = useProjectStore()
opacity: 1;
}
}
</style>
</style>

View File

@ -0,0 +1,77 @@
<template>
<ul class="list-grid">
<li
v-for="(item, index) in filteredLists"
:key="`list_${item.id}_${index}`"
class="list-grid-item"
>
<ListCard :list="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IList} from '@/modelTypes/IList'
import ListCard from './ListCard.vue'
const props = defineProps({
lists: {
type: Array as PropType<IList[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredLists = computed(() => {
return props.showArchived
? props.lists
: props.lists.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
$list-height: 150px;
$list-spacing: 1rem;
.list-grid {
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--list-columns), 1fr);
grid-auto-rows: $list-height;
gap: $list-spacing;
@media screen and (min-width: $mobile) {
--list-rows: 4;
--list-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--list-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--list-columns: 3;
--list-rows: 3;
}
@media screen and (min-width: $widescreen) {
--list-columns: 5;
--list-rows: 2;
}
}
.list-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -32,7 +32,7 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'

View File

@ -20,7 +20,7 @@
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:model-value="change()"
>
@ -147,7 +147,6 @@
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels
:creatable="false"
v-model="entities.labels"
@update:model-value="changeLabelFilter"
/>
@ -155,14 +154,24 @@
</div>
<template
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)">
v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field">
<label class="label">{{ $t('project.lists') }}</label>
<label class="label">{{ $t('list.lists') }}</label>
<div class="control">
<SelectProject
v-model="entities.projects"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
<SelectList
v-model="entities.lists"
@select="changeMultiselectFilter('lists', 'list_id')"
@remove="changeMultiselectFilter('lists', 'list_id')"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.namespaces') }}</label>
<div class="control">
<SelectNamespace
v-model="entities.namespace"
@select="changeMultiselectFilter('namespace', 'namespace')"
@remove="changeMultiselectFilter('namespace', 'namespace')"
/>
</div>
</div>
@ -180,7 +189,8 @@ import {camelCase} from 'camel-case'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {useLabelStore} from '@/stores/labels'
@ -190,19 +200,21 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue'
import SelectList from '@/components/input/SelectList.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user'
import ProjectService from '@/services/project'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
sort_by: [],
order_by: [],
@ -227,7 +239,8 @@ const DEFAULT_FILTERS = {
reminders: '',
assignees: '',
labels: '',
project_id: '',
list_id: '',
namespace: '',
} as const
const props = defineProps({
@ -251,21 +264,24 @@ const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()),
lists: shallowReactive(new ListService()),
namespace: shallowReactive(new NamespaceService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
projects: IProject[]
lists: IList[]
namespace: INamespace[]
}
type EntityType = 'users' | 'labels' | 'projects'
type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
const entities: Entities = reactive({
users: [],
labels: [],
projects: [],
lists: [],
namespace: [],
})
onMounted(() => {
@ -311,7 +327,8 @@ function prepareFilters() {
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('projects', 'project_id')
prepareRelatedObjectFilter('lists', 'list_id')
prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels')

View File

@ -1,30 +1,30 @@
import {ref, watch, type Ref} from 'vue'
import ProjectService from '@/services/project'
import type {IProject} from '@/modelTypes/IProject'
import ListService from '@/services/list'
import type {IList} from '@/modelTypes/IList'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export function useProjectBackground(project: Ref<IProject>) {
export function useListBackground(list: Ref<IList>) {
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
watch(
() => [project.value.id, project.value.backgroundBlurHash] as [IProject['id'], IProject['backgroundBlurHash']],
async ([projectId, blurHash], oldValue) => {
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([listId, blurHash], oldValue) => {
if (
project.value === null ||
!project.value.backgroundInformation ||
list.value === null ||
!list.value.backgroundInformation ||
backgroundLoading.value
) {
return
}
const [oldProjectId, oldBlurHash] = oldValue || []
const [oldListId, oldBlurHash] = oldValue || []
if (
oldValue !== undefined &&
projectId === oldProjectId && blurHash === oldBlurHash
oldValue !== undefined &&
listId === oldListId && blurHash === oldBlurHash
) {
// project hasn't changed
// list hasn't changed
return
}
@ -35,8 +35,8 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
})
const projectService = new ProjectService()
const backgroundPromise = projectService.background(project.value).then((result) => {
const listService = new ListService()
const backgroundPromise = listService.background(list.value).then((result) => {
background.value = result
})
await Promise.all([blurHashPromise, backgroundPromise])
@ -44,7 +44,7 @@ export function useProjectBackground(project: Ref<IProject>) {
backgroundLoading.value = false
}
},
{immediate: true},
{ immediate: true },
)
return {
@ -52,4 +52,4 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl,
backgroundLoading,
}
}
}

View File

@ -24,12 +24,12 @@
}"
>
<div :class="{'content': hasContent}">
<slot/>
<slot />
</div>
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer"/>
<slot name="footer" />
</footer>
</div>
</template>
@ -76,27 +76,22 @@ defineEmits(['close'])
<style lang="scss" scoped>
.card {
background-color: var(--white);
border-radius: $radius;
margin-bottom: 1rem;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
@media print {
box-shadow: none;
border: none;
}
background-color: var(--white);
border-radius: $radius;
margin-bottom: 1rem;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
}
.card-header {
box-shadow: none;
border-bottom: 1px solid var(--card-border-color);
border-radius: $radius $radius 0 0;
box-shadow: none;
border-bottom: 1px solid var(--card-border-color);
border-radius: $radius $radius 0 0;
}
.card-footer {
background-color: var(--grey-50);
border-top: 0;
background-color: var(--grey-50);
border-top: 0;
padding: var(--modal-card-head-padding);
display: flex;
justify-content: flex-end;

View File

@ -1,6 +1,6 @@
<template>
<modal @close="close()">
<card class="has-background-white keyboard-shortcuts" :shadow="false" :title="$t('keyboardShortcuts.title')">
<card class="has-background-white has-no-shadow keyboard-shortcuts" :title="$t('keyboardShortcuts.title')">
<template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3>

View File

@ -44,8 +44,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.projects',
keys: ['g', 'p'],
title: 'keyboardShortcuts.navigation.namespaces',
keys: ['g', 'n'],
combination: 'then',
},
{
@ -61,8 +61,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.kanban',
title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
@ -71,26 +71,26 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'keyboardShortcuts.project.title',
available: (route) => (route.name as string)?.startsWith('project.'),
title: 'keyboardShortcuts.list.title',
available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [
{
title: 'keyboardShortcuts.project.switchToListView',
title: 'keyboardShortcuts.list.switchToListView',
keys: ['g', 'l'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToGanttView',
title: 'keyboardShortcuts.list.switchToGanttView',
keys: ['g', 'g'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToTableView',
title: 'keyboardShortcuts.list.switchToTableView',
keys: ['g', 't'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToKanbanView',
title: 'keyboardShortcuts.list.switchToKanbanView',
keys: ['g', 'k'],
combination: 'then',
},
@ -140,18 +140,6 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
title: 'keyboardShortcuts.task.description',
keys: ['e'],
},
{
title: 'keyboardShortcuts.task.priority',
keys: ['p'],
},
{
title: 'keyboardShortcuts.task.delete',
keys: ['shift', 'delete'],
},
{
title: 'keyboardShortcuts.task.favorite',
keys: ['s'],
},
],
},
]
]

View File

@ -1,21 +1,13 @@
<template>
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
<div class="loader-container is-loading"></div>
</template>
<script lang="ts">
export default {
inheritAttrs: true,
inheritAttrs: false,
}
</script>
<script lang="ts" setup>
const {
variant = 'default',
} = defineProps<{
variant?: 'default' | 'small'
}>()
</script>
<style scoped lang="scss">
.loader-container {
height: 100%;
@ -28,18 +20,5 @@ const {
min-height: 50px;
min-width: 100px;
}
&.is-small {
min-width: 100%;
height: 150px;
&.is-loading::after {
width: 3rem;
height: 3rem;
top: calc(50% - 1.5rem);
left: calc(50% - 1.5rem);
border-width: 3px;
}
}
}
</style>

View File

@ -8,7 +8,7 @@
}"
ref="popup"
>
<slot name="content" :isOpen="open" :toggle="toggle"/>
<slot name="content" :isOpen="open"/>
</div>
</template>
@ -23,14 +23,11 @@ const props = defineProps({
},
})
const emit = defineEmits(['close'])
const open = ref(false)
const popup = ref<HTMLElement | null>(null)
function close() {
open.value = false
emit('close')
}
function toggle() {

View File

@ -47,7 +47,7 @@ import {success} from '@/message'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
const props = defineProps({
entity: String as ISubscription['entity'],
entity: String,
entityId: Number,
isButton: {
type: Boolean,
@ -73,18 +73,28 @@ const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
return t('task.subscription.subscribedTaskThroughParentProject')
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedListThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentList')
}
return ''
}
switch (props.entity) {
case 'project':
case 'namespace':
return props.modelValue !== null ?
t('task.subscription.subscribedProject') :
t('task.subscription.notSubscribedProject')
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'list':
return props.modelValue !== null ?
t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedList')
case 'task':
return props.modelValue !== null ?
t('task.subscription.subscribedTask') :
@ -120,8 +130,11 @@ async function subscribe() {
let message = ''
switch (props.entity) {
case 'project':
message = t('task.subscription.subscribeSuccessProject')
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.subscribeSuccessList')
break
case 'task':
message = t('task.subscription.subscribeSuccessTask')
@ -140,8 +153,11 @@ async function unsubscribe() {
let message = ''
switch (props.entity) {
case 'project':
message = t('task.subscription.unsubscribeSuccessProject')
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.unsubscribeSuccessList')
break
case 'task':
message = t('task.subscription.unsubscribeSuccessTask')

View File

@ -49,11 +49,9 @@ const displayName = computed(() => getDisplayName(props.user))
<style lang="scss" scoped>
.user {
margin: .5rem;
display: flex;
justify-items: center;
&.is-inline {
display: inline-flex;
display: inline;
}
}

View File

@ -0,0 +1,103 @@
<template>
<dropdown>
<template #trigger="triggerProps">
<slot name="trigger" v-bind="triggerProps">
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</slot>
</template>
<template v-if="namespace.isArchived">
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
</dropdown-item>
</template>
<template v-else>
<dropdown-item
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
icon="plus"
>
{{ $t('menu.newList') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
class="has-no-shadow"
:is-button="false"
entity="namespace"
:entity-id="namespace.id"
:model-value="subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
:to="{ name: 'namespace.settings.delete', params: { id: namespace.id } }"
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
</dropdown-item>
</template>
</dropdown>
</template>
<script setup lang="ts">
import {ref, onMounted, type PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
namespace: {
type: Object as PropType<INamespace>,
required: true,
},
})
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
namespaceStore.setNamespaceById({
...props.namespace,
subscription: sub,
})
}
</script>
<style scoped lang="scss">
.dropdown-trigger {
padding: 0.5rem;
display: flex;
}
</style>

View File

@ -117,9 +117,9 @@ function to(n, index) {
case names.TASK_DELETED:
// Nothing
break
case names.PROJECT_CREATED:
case names.LIST_CREATED:
to.name = 'task.index'
to.params.projectId = n.notification.project.id
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
@ -145,13 +145,12 @@ function to(n, index) {
.trigger-button {
width: 100%;
position: relative;
}
.unread-indicator {
position: absolute;
top: 1rem;
right: .5rem;
top: .75rem;
right: 1.15rem;
width: .75rem;
height: .75rem;

View File

@ -1,215 +0,0 @@
<template>
<div
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject?.isArchived}"
class="loader-container"
>
<h1 class="project-title-print">
{{ getProjectTitle(currentProject) }}
</h1>
<div class="switch-view-container d-print-none">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
>
{{ $t('project.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
{{ $t('project.archivedMessage') }}
</Message>
</CustomTransition>
<slot v-if="loadedProjectId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ProjectModel from '@/models/project'
import ProjectService from '@/services/project'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {saveProjectToHistory} from '@/modules/projectHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentProject
})
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
saveProjectToHistory(projectData)
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
// the currently loaded project has the right set.
if (
(
projectIdToLoad === loadedProjectId.value ||
typeof projectIdToLoad === 'undefined' ||
projectIdToLoad === currentProject.value?.id
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
const projectFromStore = projectStore.projects[projectData.id]
if (projectFromStore) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentProject({project: projectFromStore})
}
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
const project = new ProjectModel(projectData)
try {
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
.project-title-print {
display: none;
font-size: 1.75rem;
text-align: center;
margin-bottom: .5rem;
@media print {
display: block;
}
}
</style>

View File

@ -1,73 +0,0 @@
<template>
<ul class="project-grid">
<li
v-for="(item, index) in filteredProjects"
:key="`project_${item.id}_${index}`"
class="project-grid-item"
>
<ProjectCard :project="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectCard from './ProjectCard.vue'
const props = defineProps({
projects: {
type: Array as PropType<IProject[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredProjects = computed(() => {
return props.showArchived
? props.projects
: props.projects.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
.project-grid {
--project-grid-item-height: 150px;
--project-grid-gap: 1rem;
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--project-grid-columns), 1fr);
grid-auto-rows: var(--project-grid-item-height);
gap: var(--project-grid-gap);
@media screen and (min-width: $mobile) {
--project-grid-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--project-grid-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--project-grid-columns: 3;
}
@media screen and (min-width: $widescreen) {
--project-grid-columns: 5;
}
}
.project-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -61,31 +61,35 @@ import {useRouter} from 'vue-router'
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import ProjectModel from '@/models/project'
import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {getHistory} from '@/modules/projectHistory'
import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const labelStore = useLabelStore()
const taskStore = useTaskStore()
@ -94,20 +98,21 @@ type DoAction<Type = any> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
CMD = 'cmd',
TASK = 'task',
PROJECT = 'project',
LIST = 'list',
TEAM = 'team',
}
enum COMMAND_TYPE {
NEW_TASK = 'newTask',
NEW_PROJECT = 'newProject',
NEW_LIST = 'newList',
NEW_NAMESPACE = 'newNamespace',
NEW_TEAM = 'newTeam',
}
enum SEARCH_MODE {
ALL = 'all',
TASKS = 'tasks',
PROJECTS = 'projects',
LISTS = 'lists',
TEAMS = 'teams',
}
@ -132,25 +137,34 @@ function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
const foundProjects = computed(() => {
const { project } = parsedQuery.value
const foundLists = computed(() => {
const { list } = parsedQuery.value
if (
searchMode.value === SEARCH_MODE.ALL ||
searchMode.value === SEARCH_MODE.PROJECTS ||
project === null
searchMode.value === SEARCH_MODE.LISTS ||
list === null
) {
return []
}
const ncache: { [id: ListModel['id']]: INamespace } = {}
const history = getHistory()
const allProjects = [
const allLists = [
...new Set([
...history.map((l) => projectStore.projects[l.id]),
...projectStore.searchProject(project),
...history.map((l) => listStore.getListById(l.id)),
...listStore.searchList(list),
]),
]
return allProjects.filter(l => Boolean(l))
return allLists.filter((l) => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = namespaceStore.getNamespaceById(l.namespaceId)
}
return !ncache[l.namespaceId].isArchived
})
})
// FIXME: use fuzzysearch
@ -177,9 +191,9 @@ const results = computed<Result[]>(() => {
items: foundTasks.value,
},
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
type: ACTION_TYPE.LIST,
title: t('quickActions.lists'),
items: foundLists.value,
},
{
type: ACTION_TYPE.TEAM,
@ -191,7 +205,8 @@ const results = computed<Result[]>(() => {
const loading = computed(() =>
taskService.loading ||
projectStore.isLoading ||
namespaceStore.isLoading ||
listStore.isLoading ||
teamService.loading,
)
@ -209,11 +224,17 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
placeholder: t('quickActions.newTask'),
action: newTask,
},
newProject: {
type: COMMAND_TYPE.NEW_PROJECT,
title: t('quickActions.cmds.newProject'),
placeholder: t('quickActions.newProject'),
action: newProject,
newList: {
type: COMMAND_TYPE.NEW_LIST,
title: t('quickActions.cmds.newList'),
placeholder: t('quickActions.newList'),
action: newList,
},
newNamespace: {
type: COMMAND_TYPE.NEW_NAMESPACE,
title: t('quickActions.cmds.newNamespace'),
placeholder: t('quickActions.newNamespace'),
action: newNamespace,
},
newTeam: {
type: COMMAND_TYPE.NEW_TEAM,
@ -225,20 +246,26 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
const currentList = computed(() => Object.keys(baseStore.currentList).length === 0
? null
: baseStore.currentProject,
: baseStore.currentList,
)
const hintText = computed(() => {
if (selectedCmd.value !== null && currentProject.value !== null) {
let namespace
if (selectedCmd.value !== null && currentList.value !== null) {
switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK:
return t('quickActions.createTask', {
title: currentProject.value.title,
title: currentList.value.title,
})
case COMMAND_TYPE.NEW_LIST:
namespace = namespaceStore.getNamespaceById(
currentList.value.namespaceId,
)
return t('quickActions.createList', {
title: namespace?.title,
})
case COMMAND_TYPE.NEW_PROJECT:
return t('quickActions.createProject')
}
}
const prefixes =
@ -248,10 +275,10 @@ const hintText = computed(() => {
const availableCmds = computed(() => {
const cmds = []
if (currentProject.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject)
if (currentList.value !== null) {
cmds.push(commands.value.newTask, commands.value.newList)
}
cmds.push(commands.value.newTeam)
cmds.push(commands.value.newNamespace, commands.value.newTeam)
return cmds
})
@ -261,21 +288,21 @@ const searchMode = computed(() => {
if (query.value === '') {
return SEARCH_MODE.ALL
}
const { text, project, labels, assignees } = parsedQuery.value
const { text, list, labels, assignees } = parsedQuery.value
if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS
}
if (
assignees.length === 0 &&
project !== null &&
list !== null &&
text === '' &&
labels.length === 0
) {
return SEARCH_MODE.PROJECTS
return SEARCH_MODE.LISTS
}
if (
assignees.length > 0 &&
project === null &&
list === null &&
text === '' &&
labels.length === 0
) {
@ -329,7 +356,7 @@ function searchTasks() {
taskSearchTimeout.value = null
}
const { text, project: projectName, labels } = parsedQuery.value
const { text, list: listName, labels } = parsedQuery.value
const filters: Filter[] = []
@ -346,10 +373,10 @@ function searchTasks() {
})
}
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
if (project !== null) {
addFilter('projectId', project.id, 'equals')
if (listName !== null) {
const list = listStore.findListByExactname(listName)
if (list !== null) {
addFilter('listId', list.id, 'equals')
}
}
@ -369,9 +396,9 @@ function searchTasks() {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
const project = projectStore.projects[t.projectId]
if (project !== null) {
t.title = `${t.title} (${project.title})`
const list = listStore.getListById(t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
}
return t
})
@ -401,7 +428,7 @@ function searchTeams() {
teamService.getAll({}, { s: t }),
)
const teamsResult = await Promise.all(teamSearchPromises)
foundTeams.value = teamsResult.flat().map((team) => {
foundTeams.value = teamsResult.flatMap((team) => {
team.title = team.name
return team
})
@ -417,11 +444,11 @@ const searchInput = ref<HTMLElement | null>(null)
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.PROJECT:
case ACTION_TYPE.LIST:
closeQuickActions()
await router.push({
name: 'project.index',
params: { projectId: (item as DoAction<IProject>).id },
name: 'list.index',
params: { listId: (item as DoAction<IList>).id },
})
break
case ACTION_TYPE.TASK:
@ -431,13 +458,6 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
params: { id: (item as DoAction<ITask>).id },
})
break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: { id: (item as DoAction<ITeam>).id },
})
break
case ACTION_TYPE.CMD:
query.value = ''
selectedCmd.value = item as DoAction<Command>
@ -462,25 +482,36 @@ async function doCmd() {
}
async function newTask() {
if (currentProject.value === null) {
if (currentList.value === null) {
return
}
const task = await taskStore.createNewTask({
title: query.value,
projectId: currentProject.value.id,
listId: currentList.value.id,
})
success({ message: t('task.createSuccess') })
await router.push({ name: 'task.detail', params: { id: task.id } })
}
async function newProject() {
if (currentProject.value === null) {
async function newList() {
if (currentList.value === null) {
return
}
await projectStore.createProject(new ProjectModel({
const newList = await listStore.createList(new ListModel({
title: query.value,
namespaceId: currentList.value.namespaceId,
}))
success({ message: t('project.create.createdSuccess')})
success({ message: t('list.create.createdSuccess')})
await router.push({
name: 'list.index',
params: { listId: newList.id },
})
}
async function newNamespace() {
const newNamespace = new NamespaceModel({ title: query.value })
await namespaceStore.createNamespace(newNamespace)
success({ message: t('namespace.create.success') })
}
async function newTeam() {

View File

@ -1,39 +1,39 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.links.title') }}
{{ $t('list.share.links.title') }}
<span
class="is-size-7 has-text-grey is-italic ml-3"
v-tooltip="$t('project.share.links.explanation')">
{{ $t('project.share.links.what') }}
v-tooltip="$t('list.share.links.explanation')">
{{ $t('list.share.links.what') }}
</span>
</p>
<div class="sharables-project">
<div class="sharables-list">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
{{ $t('project.share.links.create') }}
{{ $t('list.share.links.create') }}
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field">
<label class="label" for="linkShareRight">
{{ $t('project.share.right.title') }}
{{ $t('list.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<option :value="RIGHTS.READ">
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</option>
<option :value="RIGHTS.READ_WRITE">
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</option>
<option :value="RIGHTS.ADMIN">
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</option>
</select>
</div>
@ -41,21 +41,21 @@
</div>
<div class="field">
<label class="label" for="linkShareName">
{{ $t('project.share.links.name') }}
{{ $t('list.share.links.name') }}
</label>
<div class="control">
<input
id="linkShareName"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
v-tooltip="$t('project.share.links.nameExplanation')"
:placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('list.share.links.nameExplanation')"
v-model="name"
/>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
{{ $t('project.share.links.password') }}
{{ $t('list.share.links.password') }}
</label>
<div class="control">
<input
@ -63,25 +63,25 @@
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('project.share.links.passwordExplanation')"
v-tooltip="$t('list.share.links.passwordExplanation')"
v-model="password"
/>
</div>
</div>
<x-button @click="add(projectId)" icon="plus">
{{ $t('project.share.share') }}
<x-button @click="add(listId)" icon="plus">
{{ $t('list.share.share') }}
</x-button>
</div>
<table
class="table has-actions is-striped is-hoverable is-fullwidth"
class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
v-if="linkShares.length > 0"
>
<thead>
<tr>
<th></th>
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
<th>{{ $t('list.share.links.view') }}</th>
<th>{{ $t('list.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
@ -92,7 +92,7 @@
</p>
<p class="mb-2">
<i18n-t keypath="project.share.links.sharedBy" scope="global">
<i18n-t keypath="list.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
@ -102,19 +102,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</template>
</p>
@ -172,14 +172,14 @@
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="remove(projectId)"
@submit="remove(listId)"
>
<template #header>
<span>{{ $t('project.share.links.remove') }}</span>
<span>{{ $t('list.share.links.remove') }}</span>
</template>
<template #text>
<p>{{ $t('project.share.links.removeText') }}</p>
<p>{{ $t('list.share.links.removeText') }}</p>
</template>
</modal>
</div>
@ -193,19 +193,19 @@ import {RIGHTS} from '@/constants/rights'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import type {ListView} from '@/types/ListView'
import {LIST_VIEWS} from '@/types/ListView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
projectId: {
listId: {
default: 0,
required: true,
},
@ -222,20 +222,20 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView>
type SelectedViewMapper = Record<IList['id'], ListView>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
const availableViews = computed<Record<ListView, string>>(() => ({
list: t('list.list.title'),
gantt: t('list.gantt.title'),
table: t('list.table.title'),
kanban: t('list.kanban.title'),
}))
const copy = useCopyToClipboard()
watch(
() => props.projectId,
() => props.listId,
load,
{immediate: true},
)
@ -243,23 +243,23 @@ watch(
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
async function load(projectId: IProject['id']) {
// If projectId == 0 the project on the calling component wasn't already loaded, so we just bail out here
if (projectId === 0) {
async function load(listId: IList['id']) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
return
}
const links = await linkShareService.getAll({projectId})
const links = await linkShareService.getAll({listId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'project'
selectedView.value[l.id] = 'list'
})
linkShares.value = links
}
async function add(projectId: IProject['id']) {
async function add(listId: IList['id']) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
projectId,
listId,
name: name.value,
password: password.value,
})
@ -268,31 +268,31 @@ async function add(projectId: IProject['id']) {
name.value = ''
password.value = ''
showNewForm.value = false
success({message: t('project.share.links.createSuccess')})
await load(projectId)
success({message: t('list.share.links.createSuccess')})
await load(listId)
}
async function remove(projectId: IProject['id']) {
async function remove(listId: IList['id']) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
projectId,
listId,
}))
success({message: t('project.share.links.deleteSuccess')})
await load(projectId)
success({message: t('list.share.links.deleteSuccess')})
await load(listId)
} finally {
showDeleteModal.value = false
}
}
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
}
</script>
<style lang="scss" scoped>
// FIXME: I think this is not needed
.sharables-project:not(.card-content) {
.sharables-list:not(.card-content) {
overflow-y: auto
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.userTeam.shared', {type: shareTypeNames}) }}
{{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
@ -19,7 +19,7 @@
/>
</p>
<p class="control">
<x-button @click="add()">{{ $t('project.share.share') }}</x-button>
<x-button @click="add()">{{ $t('list.share.share') }}</x-button>
</p>
</div>
</div>
@ -31,7 +31,7 @@
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
</template>
</td>
</template>
@ -52,19 +52,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</template>
</td>
<td class="actions" v-if="userIsAdmin">
@ -78,19 +78,19 @@
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
>
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</option>
</select>
</div>
@ -110,7 +110,7 @@
</table>
<nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing>
<modal
@ -120,11 +120,11 @@
>
<template #header>
<span>{{
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
$t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
</template>
<template #text>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template>
</modal>
</div>
@ -139,17 +139,25 @@ import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import UserProjectService from '@/services/userProject'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserListService from '@/services/userList'
import UserListModel from '@/models/userList'
import type {IUserList} from '@/modelTypes/IUserList'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import TeamProjectService from '@/services/teamProject'
import TeamProjectModel from '@/models/teamProject'
import type { ITeamProject } from '@/modelTypes/ITeamProject'
import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamListService from '@/services/teamList'
import TeamListModel from '@/models/teamList'
import type { ITeamList } from '@/modelTypes/ITeamList'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
@ -162,15 +170,13 @@ import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
const props = defineProps({
type: {
type: String as PropType<'project'>,
type: String as PropType<'list' | 'namespace'>,
default: '',
},
shareType: {
type: String as PropType<'user' | 'team'>,
type: String as PropType<'user' | 'team' | 'namespace'>,
default: '',
},
id: {
@ -185,9 +191,9 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
// This user service is a userProjectService, depending on the type we are using
let stuffService: UserProjectService | TeamProjectService
let stuffModel: IUserProject | ITeamProject
// This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: UserNamespaceService | UserListService | TeamListService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserList | ITeamList | ITeamNamespace
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
@ -195,7 +201,7 @@ const searchLabel = ref('')
const selectedRight = ref({})
// This holds either teams or users who this namepace or project is shared with
// This holds either teams or users who this namepace or list is shared with
const sharables = ref([])
const showDeleteModal = ref(false)
@ -206,11 +212,11 @@ const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
if (props.shareType === 'user') {
return t('project.share.userTeam.typeUser', count)
return t('list.share.userTeam.typeUser', count)
}
if (props.shareType === 'team') {
return t('project.share.userTeam.typeTeam', count)
return t('list.share.userTeam.typeTeam', count)
}
return ''
@ -221,8 +227,12 @@ const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1)
const sharableName = computed(() => {
if (props.type === 'project') {
return t('project.list.title')
if (props.type === 'list') {
return t('list.list.title')
}
if (props.shareType === 'namespace') {
return t('namespace.namespace')
}
return ''
@ -234,9 +244,14 @@ if (props.shareType === 'user') {
sharable = ref(new UserModel())
searchLabel.value = 'username'
if (props.type === 'project') {
stuffService = shallowReactive(new UserProjectService())
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
if (props.type === 'list') {
stuffService = shallowReactive(new UserListService())
stuffModel = reactive(new UserListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new UserNamespaceService())
stuffModel = reactive(new UserNamespaceModel({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}
@ -246,9 +261,14 @@ if (props.shareType === 'user') {
sharable = ref(new TeamModel())
searchLabel.value = 'name'
if (props.type === 'project') {
stuffService = shallowReactive(new TeamProjectService())
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
if (props.type === 'list') {
stuffService = shallowReactive(new TeamListService())
stuffModel = reactive(new TeamListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new TeamNamespaceService())
stuffModel = reactive(new TeamNamespaceModel({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}
@ -283,7 +303,7 @@ async function deleteSharable() {
}
}
success({
message: t('project.share.userTeam.removeSuccess', {
message: t('list.share.userTeam.removeSuccess', {
type: shareTypeName.value,
sharable: sharableName.value,
}),
@ -306,7 +326,7 @@ async function add(admin) {
}
await stuffService.create(stuffModel)
success({message: t('project.share.userTeam.addedSuccess', {type: shareTypeName.value})})
success({message: t('list.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load()
}
@ -338,7 +358,7 @@ async function toggleType(sharable) {
sharables.value[i].right = r.right
}
}
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
success({message: t('list.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
}
const found = ref([])

View File

@ -38,8 +38,9 @@
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs, onActivated} from 'vue'
import {computed, ref, watch, toRefs} from 'vue'
import {useRouter} from 'vue-router'
import {useNow} from '@vueuse/core'
import {getHexColor} from '@/models/task'
@ -49,7 +50,7 @@ import {parseKebabDate} from '@/helpers/time/parseKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
import {
extendDayjs,
@ -156,8 +157,7 @@ function openTask(e: {
const weekDayFromDate = useWeekDayFromDate()
const today = ref(new Date())
onActivated(() => today.value = new Date())
const today = useNow()
const dateIsToday = computed(() => (date: Date) => {
return (
date.getDate() === today.value.getDate() &&

View File

@ -5,7 +5,7 @@
<textarea
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('project.list.addPlaceholder')"
:placeholder="$t('list.list.addPlaceholder')"
rows="1"
v-focus
v-model="newTaskTitle"
@ -24,10 +24,10 @@
@click="addTask()"
icon="plus"
:loading="loading"
:aria-label="$t('project.list.add')"
:aria-label="$t('list.list.add')"
>
<span class="button-text">
{{ $t('project.list.add') }}
{{ $t('list.list.add') }}
</span>
</x-button>
</p>
@ -107,7 +107,7 @@ const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('project.create.addTitleRequired')
errorMessage.value = t('list.create.addTitleRequired')
return
}
errorMessage.value = ''
@ -128,20 +128,20 @@ async function addTask() {
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title) ?? [])
await taskStore.ensureLabelsExist(allLabels.flat())
const newTasks = tasksToCreate.map(async ({title, project}) => {
const newTasks = tasksToCreate.map(async ({title, list}) => {
if (title === '') {
return
}
// If the task has a project specified, make sure to use it
let projectId = null
if (project !== null) {
projectId = await taskStore.findProjectId({project, projectId: 0})
// If the task has a list specified, make sure to use it
let listId = null
if (list !== null) {
listId = await taskStore.findListId({list, listId: 0})
}
const task = await taskStore.createNewTask({
title,
projectId: projectId || authStore.settings.defaultProjectId,
listId: listId || authStore.settings.defaultListId,
position: props.defaultPosition,
})
createdTasks[title] = task
@ -176,7 +176,7 @@ async function addTask() {
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the project
// we're only emitting here so that the relation shows up in the task list
emit('taskAdded', createdTask)
return rel
@ -184,8 +184,8 @@ async function addTask() {
await Promise.all(relations)
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_PROJECT') {
errorMessage.value = t('project.create.addProjectRequired')
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
return
}
throw e

View File

@ -1,6 +1,6 @@
<template>
<Multiselect
:loading="projectUserService.loading"
:loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
@ -30,7 +30,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import ProjectUserService from '@/services/projectUsers'
import ListUserService from '@/services/listUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
@ -42,7 +42,7 @@ const props = defineProps({
type: Number,
required: true,
},
projectId: {
listId: {
type: Number,
required: true,
},
@ -59,7 +59,7 @@ const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const projectUserService = shallowReactive(new ProjectUserService())
const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([])
let isAdding = false
@ -94,7 +94,7 @@ async function addAssignee(user: IUser) {
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project
// Remove the assignee from the list
for (const a in assignees.value) {
if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1)
@ -109,7 +109,7 @@ async function findUser(query: string) {
return
}
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[]
const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned
foundUsers.value = response

View File

@ -7,7 +7,7 @@
:search-results="foundLabels"
@select="addLabel"
label="title"
:creatable="creatable"
:creatable="true"
@create="createAndAddLabel"
:create-placeholder="$t('task.label.createPlaceholder')"
v-model="labels"
@ -65,10 +65,6 @@ const props = defineProps({
disabled: {
default: false,
},
creatable: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])

View File

@ -0,0 +1,86 @@
<template>
<Multiselect
class="control is-expanded"
:placeholder="$t('list.search')"
:search-results="foundLists"
label="title"
:select-placeholder="$t('list.searchSelect')"
:model-value="list"
@update:model-value="Object.assign(list, $event)"
@select="select"
@search="findLists"
>
<template #searchResult="{option}">
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
{{ (option as IList).title }}
</template>
</Multiselect>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import ListModel from '@/models/list'
import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({
modelValue: {
type: Object as PropType<IList>,
required: false,
},
})
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const list: IList = reactive(new ListModel())
watch(
() => props.modelValue,
(newList) => Object.assign(list, newList),
{
immediate: true,
deep: true,
},
)
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const foundLists = ref<IList[]>([])
function findLists(query: string) {
if (query === '') {
select(null)
}
foundLists.value = listStore.searchList(query)
}
function select(l: IList | null) {
if (l === null) {
return
}
Object.assign(list, l)
emit('update:modelValue', list)
}
function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null
? namespace.title
: t('list.shared')
}
</script>
<style lang="scss" scoped>
.list-namespace-title {
color: var(--grey-500);
}
</style>

View File

@ -1,70 +0,0 @@
<template>
<Multiselect
class="control is-expanded"
:placeholder="$t('project.search')"
:search-results="foundProjects"
label="title"
:select-placeholder="$t('project.searchSelect')"
:model-value="project"
@update:model-value="Object.assign(project, $event)"
@select="select"
@search="findProjects"
>
<template #searchResult="{option}">
<span class="has-text-grey" v-if="projectStore.getAncestors(option).length > 1">
{{ projectStore.getAncestors(option).filter(p => p.id !== option.id).map(p => getProjectTitle(p)).join(' &gt; ') }} &gt;
</span>
{{ getProjectTitle(option) }}
</template>
</Multiselect>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import ProjectModel from '@/models/project'
import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({
modelValue: {
type: Object as PropType<IProject>,
required: false,
},
})
const emit = defineEmits(['update:modelValue'])
const project: IProject = reactive(new ProjectModel())
watch(
() => props.modelValue,
(newProject) => Object.assign(project, newProject),
{
immediate: true,
deep: true,
},
)
const projectStore = useProjectStore()
const foundProjects = ref<IProject[]>([])
function findProjects(query: string) {
if (query === '') {
select(null)
}
foundProjects.value = projectStore.searchProject(query)
}
function select(l: IProject | null) {
if (l === null) {
return
}
Object.assign(project, l)
emit('update:modelValue', project)
}
</script>

Some files were not shown because too many files have changed in this diff Show More