Compare commits

..

1 commit

Author SHA1 Message Date
kolaente
e59fa41600
feat: add tests for finding the api url 2021-11-16 22:41:40 +01:00
547 changed files with 31690 additions and 44862 deletions

View file

@ -29,91 +29,26 @@ steps:
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# debug: true
# restore: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: dependencies
image: node:18-alpine
image: node:16
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- yarn --frozen-lockfile --network-timeout 100000
# depends_on:
# - restore-cache
- name: lint
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run lint
depends_on:
- dependencies
- name: build-prod
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run build
depends_on:
- dependencies
- name: test-unit
image: node:18-alpine
pull: true
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:18-alpine
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.14.0-chrome99-ff97
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_RECORD_KEY:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm cypress install
- pnpm run serve:dist & npx wait-on http://localhost:4173
- pnpm run test:frontend --browser chrome --record
depends_on:
- build-prod
# - name: rebuild-cache
# image: meltwater/drone-cache:dev
# pull: true
@ -128,14 +63,82 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
# depends_on:
# - dependencies
- name: lint
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn run lint
depends_on:
- dependencies
- name: build-prod
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- yarn build
depends_on:
- dependencies
- name: test-unit
image: node:16
pull: true
commands:
- yarn test:unit
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node14.17.0-chrome91-ff89
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
commands:
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:5000
- yarn test:frontend --browser chrome
depends_on:
- dependencies
- build-prod
- name: upload-test-results
image: plugins/s3
pull: true
settings:
bucket: drone-test-results
access_key:
from_secret: test_results_aws_access_key_id
secret_key:
from_secret: test_results_aws_secret_access_key
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
source: cypress/screenshots/**/**/*
strip_prefix: cypress/screenshots/
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
depends_on:
- test-frontend
when:
status:
- failure
- success
- name: deploy-preview
image: node:18-alpine
image: node:16
pull: true
environment:
NETLIFY_AUTH_TOKEN:
@ -145,10 +148,6 @@ steps:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
@ -191,22 +190,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:18-alpine
image: node:16
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- corepack enable && pnpm config set store-dir .cache/.pnp
- pnpm install --fetch-timeout 100000
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
@ -267,22 +265,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:18-alpine
image: node:16
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
@ -659,6 +656,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: c885a0e50db729842402494aa645dd3ac662828b691108550f6bf302158295ba
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
...

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -1,54 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
'root': true,
'env': {
'browser': true,
'es2021': true,
'node': true,
'vue/setup-compiler-macros': true,
},
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended',
],
'rules': {
'vue/html-quotes': [
'error',
'double',
],
'quotes': [
'error',
'single',
],
'comma-dangle': [
'error',
'always-multiline',
],
'semi': [
'error',
'never',
],
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
'vue/multi-word-component-names': 0,
},
'parser': 'vue-eslint-parser',
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022,
'sourceType': 'module',
},
'ignorePatterns': [
'*.test.*',
'cypress/*',
],
'globals': {
'defineProps': 'readonly',
},
}

View file

@ -1,44 +0,0 @@
<!--
Please fill out this issue template to report a bug.
If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
-->
**Version information:**
Frontend Version:
API Version:
Browser and OS Version:
**Steps to reproduce:**
<!--
Add clear steps to reproduce the bug. Provide screenshots where applicable.
-->
1.
2.
...
**Expected behavior:**
<!--
Describe what happened.
-->
**Actual behavior:**
<!--
Describe what happened instead.
-->
**Checklist:**
* [ ] I have provided all required information
* [ ] I am using the latest release or the latest unstable build
* [ ] I was able to reproduce the bug on [try](https://try.vikunja.io)

View file

@ -1,58 +0,0 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
labels: kind/bug
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue.
- type: markdown
attributes:
value: |
Please fill out this issue template to report a bug.
1. If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report and closed.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below).
- type: input
id: frontend-version
attributes:
label: Vikunja Frontend Version
description: Vikunja frontend version (or commit reference) of your instance
validations:
required: true
- type: input
id: api-version
attributes:
label: Vikunja API Version
description: Vikunja API version (or commit reference) of your instance
validations:
required: true
- type: input
id: browser-version
attributes:
label: Browser and version
description: If your issue is related to a frontend problem, please provide the browser and version you used to reproduce it.
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Vikunja demo site?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots

View file

@ -1,17 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: API issues
url: https://code.vikunja.io/api/issues
about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first.
- name: Forum
url: https://community.vikunja.io/
about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum.
- name: Security-related issues
url: https://vikunja.io/contact/#security
about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue.
- name: Chat on Matrix
url: https://matrix.to/#/#vikunja:matrix.org
about: Please ask any quick questions here.
- name: Translations
url: https://crowdin.com/project/vikunja
about: Any problems or requests for new languages about translations should be handled in crowdin.

5
.gitignore vendored
View file

@ -2,21 +2,16 @@
node_modules
/dist*
*.zip
.direnv/
# local env files
.env.local
.env.*.local
# Log files
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
stats.html
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.idea

2
.npmrc
View file

@ -1,2 +0,0 @@
auto-install-peers=true
fetch-timeout=100000

2
.nvmrc
View file

@ -1 +1 @@
v18
v16

View file

@ -3,8 +3,7 @@
"codezombiech.gitignore",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"vue.volar",
"vue.vscode-typescript-vue-plugin",
"johnsoncodehk.volar",
"lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel",
"mikestead.dotenv",

View file

@ -1,5 +1,5 @@
{
"eslint.packageManager": "pnpm",
"eslint.packageManager": "yarn",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
@ -18,11 +18,13 @@
"javascriptreact",
"vue"
],
"vetur.validation.template": false,
// i18n ally
"i18n-ally.localesPaths": [
"src/i18n/lang"
],
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested"
"i18n-ally.keystyle": "nested",
}

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,28 @@
# Stage 1: Build application
FROM node:18-alpine AS compile-image
FROM node:16 AS compile-image
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ADD . ./
ENV YARN_CACHE_FOLDER .cache/yarn/
COPY . ./
RUN \
if [ $USE_RELEASE = true ]; then \
rm -rf dist/ && \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm install && \
apk add --no-cache git && \
yarn install --frozen-lockfile --network-timeout 100000 && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
yarn run build
# Stage 2: copy
FROM nginx:alpine
FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
@ -40,10 +36,4 @@ ENV PGID 1000
LABEL maintainer="maintainers@vikunja.io"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
CMD "/run.sh"

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.19.1-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.18.1-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.
@ -22,27 +22,23 @@ There is a [docker image available](https://hub.docker.com/r/vikunja/api) with s
## Project setup
```shell
pnpm install
yarn install
```
### Compiles and hot-reloads for development
```shell
pnpm run serve
yarn run serve
```
### Compiles and minifies for production
```shell
pnpm run build
yarn run build
```
### Lints and fixes files
```shell
pnpm run lint
yarn run lint
```
## Sponsors
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)

View file

@ -1,59 +0,0 @@
[changelog]
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
* *({{commit.scope}})* {{ commit.message | upper_first }}
{%- if commit.breaking %}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
* {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
{% if commit.breaking -%}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{% endif -%}
{% endif -%}
{% endfor -%}
{% raw %}\n{% endraw %}\
{% endfor %}\n
"""
#{% for group, commits in commits | group_by(attribute="group") %}
# ### {{ group | upper_first }}
# {% for commit in commits %}\
# - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
# {% endfor %}\
#{% endfor %}\n
# remove the leading and trailing whitespace from the template
trim = true
[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
{ message = ".*(deps).*", group = "Dependencies"},
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
{ message = ".*", group = "Other", default_scope = "other"}, # Everything that's not a conventional commit goes into the "Other" category
]

View file

@ -1,25 +0,0 @@
import {defineConfig} from 'cypress'
export default defineConfig({
env: {
API_URL: 'http://localhost:3456/api/v1',
TEST_SECRET: 'averyLongSecretToSe33dtheDB',
},
video: false,
retries: {
runMode: 2,
},
projectId: '181c7x',
e2e: {
baseUrl: 'http://localhost:4173',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
viewportWidth: 1600,
viewportHeight: 900,
})

11
cypress.json Normal file
View file

@ -0,0 +1,11 @@
{
"baseUrl": "http://localhost:5000",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "averyLongSecretToSe33dtheDB"
},
"video": false,
"retries": {
"runMode": 2
}
}

View file

@ -36,7 +36,7 @@ to get a shell inside the cypress container.
In that shell you can then execute the tests with
```shell
pnpm run test:frontend
yarn test:frontend
```
### Using The Cypress Dashboard
@ -44,5 +44,5 @@ pnpm run test:frontend
To open the Cypress Dashboard and run tests from there, run
```shell
pnpm run cypress:open
yarn cypress:open
```

View file

@ -9,7 +9,7 @@ services:
ports:
- 3456:3456
cypress:
image: cypress/browsers:node16.14.0-chrome99-ff97
image: cypress/browsers:node12.18.3-chrome87-ff82
volumes:
- ..:/project
- $HOME/.cache:/home/node/.cache/

View file

@ -1,56 +0,0 @@
import {ListFactory} from '../../factories/list'
import '../../support/authenticateUser'
import {prepareLists} from './prepareLists'
describe('List History', () => {
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('.list-cards-wrapper-2-rows')
.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

@ -1,78 +0,0 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})

View file

@ -1,196 +0,0 @@
import {BucketFactory} from '../../factories/bucket'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Kanban', () => {
let buckets
prepareLists()
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.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')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})

View file

@ -1,109 +0,0 @@
import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View List', () => {
prepareLists()
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/lists/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
.should('contain.text', newTaskTitle)
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title .icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
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, {
list_id: lists[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)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[1].title)
})
})

View file

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

View file

@ -1,120 +0,0 @@
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('Lists', () => {
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 h1')
.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 h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .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 h1')
.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 .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

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

View file

@ -1,131 +0,0 @@
import {ListFactory} from '../../factories/list'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {formatISO} from 'date-fns'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import '../../support/authenticateUser'
function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
UserFactory.create(1)
NamespaceFactory.create(1)
const list = ListFactory.create()[0]
BucketFactory.create(1, {
list_id: list.id,
})
const tasks = []
let dueDate = startDueDate
for (let i = 0; i < numberOfTasks; i++) {
const now = new Date()
dueDate = (new Date(dueDate.valueOf())).setDate((new Date(dueDate.valueOf())).getDate() + 2)
tasks.push({
id: i + 1,
list_id: list.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
index: i + 1,
due_date: formatISO(dueDate),
created: formatISO(now),
updated: formatISO(now),
})
}
seed(TaskFactory.table, tasks)
return {tasks, list}
}
describe('Home Page Task Overview', () => {
it('Should show tasks with a near due date first on the home page overview', () => {
const {tasks} = seedTasks()
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show overdue tasks first, then show other tasks', () => {
const oldDate = (new Date()).setDate((new Date()).getDate() - 14)
const {tasks} = seedTasks(100, oldDate)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
due_date: formatISO(new Date()),
}, false)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.first()
.should('contain.text', newTaskTitle)
})
it('Should not show a new task without a date at the bottom when there are > 50 tasks', () => {
// We're not using the api here to create the task in order to verify the flow
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('not.contain.text', newTaskTitle)
})
it('Should show a new task without a date at the bottom when there are < 50 tasks', () => {
seedTasks(40)
const newTaskTitle = 'New Task'
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
}, false)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default list at the bottom', () => {
const {list} = seedTasks(40)
updateUserSettings({
default_list_id: list.id,
overdue_tasks_reminders_time: '9:00',
})
const newTaskTitle = 'New Task'
cy.visit('/')
cy.get('.add-task-textarea')
.type(`${newTaskTitle}{enter}`)
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
})

View file

@ -1,44 +0,0 @@
import '../../support/authenticateUser'
import {createLists} from '../list/prepareLists'
function logout() {
cy.get('.navbar .user .username')
.click()
cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout')
.click()
}
describe('Log out', () => {
it('Logs the user out', () => {
cy.visit('/')
expect(localStorage.getItem('token')).to.not.eq(null)
logout()
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('token')).to.eq(null)
})
})
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('listHistory')).to.not.eq(null)
})
logout()
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('listHistory')).to.eq(null)
})
})
})

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View file

@ -1,6 +1,6 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import {faker} from '@faker-js/faker'
import faker from 'faker'
export class LinkShareFactory extends Factory {
static table = 'link_shares'

View file

@ -1,6 +1,6 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import {faker} from '@faker-js/faker'
import faker from 'faker'
export class ListFactory extends Factory {
static table = 'lists'

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
@ -15,7 +15,6 @@ export class TaskFactory extends Factory {
list_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',
created: formatISO(now),
updated: formatISO(now)
}

View file

@ -1,17 +0,0 @@
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
file_id: 1,
created: formatISO(now),
}
}
}

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View file

@ -1,4 +1,4 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
@ -14,7 +14,6 @@ export class UserFactory extends Factory {
username: faker.lorem.word(10) + faker.datatype.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0,
issuer: 'local',
created: formatISO(now),
updated: formatISO(now)
}

View file

@ -0,0 +1,540 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {UserListFactory} from '../../factories/users_list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
})
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', '/namespaces/1/list')
cy.get('.card-header-title')
.contains('Create a 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 h1')
.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', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
.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 .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
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, {
list_id: lists[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)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})
describe('Table View', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.should('exist')
cy.get('.table-view table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.table-view table.table th')
.contains('Priority')
.should('exist')
cy.get('.table-view table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})
describe('Gantt View', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})
describe('Kanban', () => {
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
const data = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.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')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
cy.get('.list-cards-wrapper-2-rows')
.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

@ -15,7 +15,7 @@ describe('Namepaces', () => {
it('Should be all there', () => {
cy.visit('/namespaces')
cy.get('[data-cy="namespace-title"]')
cy.get('.namespace h1 span')
.should('contain', namespaces[0].title)
})
@ -23,14 +23,14 @@ describe('Namepaces', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('[data-cy="new-namespace"]')
.should('contain', 'New namespace')
cy.get('a.button')
.contains('Create a new namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'New namespace')
.should('contain', 'Create a new namespace')
cy.get('input.input')
.type(newNamespaceTitle)
cy.get('.button')
@ -63,7 +63,7 @@ describe('Namepaces', () => {
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.card-footer .button')
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
@ -72,7 +72,7 @@ describe('Namepaces', () => {
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]')
cy.get('.content.namespaces-list')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
@ -89,7 +89,7 @@ describe('Namepaces', () => {
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
@ -116,30 +116,30 @@ describe('Namepaces', () => {
// Initial
cy.visit('/namespaces')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label.check span')
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('be.checked')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label.check span')
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/namespaces')
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
})
})

View file

@ -0,0 +1,35 @@
import '../../support/authenticateUser'
const setHours = hours => {
const date = new Date()
date.setHours(hours)
cy.clock(+date)
}
describe('Home Page', () => {
it('shows the right salutation in the night', () => {
setHours(4)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
it('shows the right salutation in the morning', () => {
setHours(8)
cy.visit('/')
cy.get('h2').should('contain', 'Good Morning')
})
it('shows the right salutation in the day', () => {
setHours(13)
cy.visit('/')
cy.get('h2').should('contain', 'Hi')
})
it('shows the right salutation in the night', () => {
setHours(20)
cy.visit('/')
cy.get('h2').should('contain', 'Good Evening')
})
it('shows the right salutation in the night again', () => {
setHours(23)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
})

View file

@ -6,57 +6,21 @@ import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labelTitle)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labelTitle)
}
function uploadAttachmentAndVerify(taskId: number) {
cy.intercept(`${Cypress.env('API_URL')}/tasks/${taskId}/attachments`).as('uploadAttachment')
cy.get('.task-view .action-buttons .button')
.contains('Add Attachments')
.click()
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment')
.should('exist')
}
describe('Task', () => {
let namespaces
let lists
let buckets
beforeEach(() => {
UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
})
TaskFactory.truncate()
UserListFactory.truncate()
})
@ -116,7 +80,6 @@ describe('Task', () => {
describe('Task Detail View', () => {
beforeEach(() => {
TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
})
it('Shows all task details', () => {
@ -153,7 +116,6 @@ describe('Task', () => {
.should('be.visible')
.should('contain', 'Done')
cy.get('.task-view .action-buttons p.created')
.scrollIntoView()
.should('be.visible')
.should('contain', 'Done')
})
@ -166,7 +128,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Mark task done!')
.contains('Done!')
.click()
cy.get('.task-view .heading .is-done')
@ -202,11 +164,11 @@ describe('Task', () => {
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .editor button')
cy.get('.task-view .details.content.description .editor a')
.click()
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]')
cy.get('.task-view .details.content.description .editor a')
.contains('Save')
.click()
@ -247,7 +209,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Move')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
@ -274,7 +236,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete')
.contains('Delete task')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task')
@ -301,7 +263,8 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('[data-cy="taskDetail.assign"]')
cy.get('.task-view .action-buttons .button')
.contains('Assign this task to a user')
.click()
cy.get('.task-view .column.assignees .multiselect input')
.type(users[1].username)
@ -334,7 +297,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.get('.remove-assignee')
.get('a.remove-assignee')
.click()
cy.get('.global-notification')
@ -354,7 +317,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.contains('Add labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
@ -377,35 +340,24 @@ describe('Task', () => {
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title)
})
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.click()
addLabelToTaskAndVerify(labels[0].title)
cy.get('.modal-content .close')
cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labels[0].title)
})
it('Can remove a label from a task', () => {
@ -421,13 +373,13 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible')
.should('contain', labels[0].title)
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.children()
.first()
.get('[data-cy="taskDetail.removeLabel"]')
.get('a.delete')
.click()
cy.get('.global-notification')
@ -450,10 +402,10 @@ describe('Task', () => {
.contains('Due Date')
.get('.date-input .datepicker .show')
.click()
cy.get('.datepicker .datepicker-popup button')
cy.get('.datepicker .datepicker-popup a')
.contains('Tomorrow')
.click()
cy.get('[data-cy="closeDatepicker"]')
cy.get('.datepicker .datepicker-popup a.button')
.contains('Confirm')
.click()
@ -464,87 +416,5 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Priority')
.click()
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.select('Urgent')
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.should('have.value', '4')
})
it('Can set the progress for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Progress')
.click()
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.select('50%')
cy.get('.global-notification')
.should('contain', 'Success')
cy.wait(200)
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.should('be.visible')
.should('have.value', '0.5')
})
it('Can add an attachment to a task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
uploadAttachmentAndVerify(tasks[0].id)
})
it('Can add an attachment to a task and see it appearing on kanban', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
uploadAttachmentAndVerify(tasks[0].id)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
})
})

View file

@ -8,7 +8,7 @@ const testAndAssertFailed = fixture => {
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.')
cy.get('div.notification.is-danger').contains('Wrong username or password.')
}
context('Login', () => {

View file

@ -0,0 +1,16 @@
import '../../support/authenticateUser'
describe('Log out', () => {
it('Logs the user out', () => {
cy.visit('/')
cy.get('.navbar .user .username')
.click()
cy.get('.navbar .user .dropdown-menu a.dropdown-item')
.contains('Logout')
.click()
cy.url()
.should('contain', '/login')
})
})

View file

@ -25,13 +25,14 @@ context('Registration', () => {
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it.only('Should fail', () => {
it('Should fail', () => {
const fixture = {
username: 'test',
password: '123456',
@ -42,7 +43,8 @@ context('Registration', () => {
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.')
cy.get('div.notification.is-danger').contains('A user with this username already exists.')
})
})

View file

@ -8,23 +8,21 @@ describe('User Settings', () => {
})
it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
cy.visit('/user/settings/avatar')
cy.get('input[name=avatarProvider][value=upload]')
.click()
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.get('input[type=file]', { timeout: 1000 })
.attachFile('image.jpg')
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100})
.trigger('mouseup')
cy.get('[data-cy="uploadAvatar"]')
cy.get('a.button.is-primary')
.contains('Upload Avatar')
.click()
cy.wait('@uploadAvatar')
cy.wait(3000) // Wait for the request to finish
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -35,7 +33,7 @@ describe('User Settings', () => {
cy.get('.general-settings .control input.input')
.first()
.type('Lorem Ipsum')
cy.get('[data-cy="saveGeneralSettings"]')
cy.get('.card.general-settings .button.is-primary')
.contains('Save')
.click()

21
cypress/plugins/index.js Normal file
View file

@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -0,0 +1,33 @@
/**
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
*
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
*/
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})

View file

@ -1,71 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
/**
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
*
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
*/
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View file

@ -1,29 +0,0 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/vue'
// Ensure global styles are loaded
import '../../src/styles/global.scss';
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)

View file

@ -1,5 +1,6 @@
import './commands'
import 'cypress-file-upload'
import '@4tw/cypress-drag-drop'
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275

View file

@ -1,26 +0,0 @@
export function updateUserSettings(settings) {
const token = `Bearer ${window.localStorage.getItem('token')}`
return cy.request({
method: 'GET',
url: `${Cypress.env('API_URL')}/user`,
headers: {
'Authorization': token,
},
})
.its('body')
.then(oldSettings => {
return cy.request({
method: 'POST',
url: `${Cypress.env('API_URL')}/user/settings/general`,
headers: {
'Authorization': token,
},
body: {
...oldSettings,
...settings,
},
})
})
}

View file

@ -1,10 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./integration/**/*", "./support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

3
env.d.ts vendored
View file

@ -1,3 +0,0 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
/// <reference types="cypress" />

View file

@ -1,25 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1664753041,
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

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

View file

@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vikunja</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="theme-color" content="#1973ff"/>

View file

@ -1,6 +1,6 @@
[build]
command = "pnpm run build"
publish = "dist-preview"
command = "yarn build"
publish = "dist"
[[redirects]]
from = "/*"

View file

@ -6,110 +6,79 @@ pid /var/run/nginx.pid;
events {
worker_connections 1024;
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/mime.types;
default_type application/octet-stream;
types {
application/manifest+json webmanifest;
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
keepalive_timeout 65;
gzip on;
gzip_disable "msie6";
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon audio/wav;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
text/plain
text/css
application/json
application/x-javascript
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript
application/vnd.ms-fontobject
application/x-font-ttf
font/opentype
image/svg+xml
image/x-icon
audio/wav;
map_hash_max_size 128;
map_hash_bucket_size 128;
map_hash_max_size 128;
map_hash_bucket_size 128;
# Expires map
map $sent_http_content_type $expires {
default off;
text/html max;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~image/ max;
~font/ max;
}
# Expires map
map $sent_http_content_type $expires {
default off;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~images/ max;
~font/ max;
}
server {
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
server {
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
server_name _;
server_name _;
expires $expires;
expires $expires;
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
root /usr/share/nginx/html;
try_files $uri $uri/ =404;
}
root /usr/share/nginx/html;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
index index.html;
}
# all assets contain hash in filename, cache forever
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# all workbox scripts are compiled with hash in filename, cache forever3
location ^~ /workbox- {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# assume that everything else is handled by the application router, by injecting the index.html.
location / {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404;
}
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
try_files $uri $uri/ =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

View file

@ -49,7 +49,7 @@
inkscape:label="ink_ext_XXXXXX 1"
style="display:inline"
transform="translate(-92.67749,-674.48297)"><circle
style="fill:#196aff;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
style="fill:#5974d9;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path920"
cx="242.67749"
cy="828.77881"

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -5,110 +5,152 @@
"scripts": {
"serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js",
"serve:dist": "vite preview --port 4173",
"serve:dist": "vite preview",
"build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"lint:markup": "vue-tsc --noEmit",
"cypress:open": "cypress open",
"test:unit": "vitest --run",
"test:unit-watch": "vitest watch",
"test:unit": "jest",
"test:frontend": "cypress run",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "npx browserslist@latest --update-db"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.2.0",
"@fortawesome/free-regular-svg-icons": "6.2.0",
"@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "2.0.1",
"@kyvg/vue3-notification": "2.4.1",
"@sentry/tracing": "7.15.0",
"@sentry/vue": "7.15.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.3.0",
"@vueuse/router": "9.3.0",
"axios": "0.27.2",
"blurhash": "2.0.3",
"bulma-css-variables": "0.9.33",
"@github/hotkey": "1.6.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.15.0",
"@sentry/vue": "6.15.0",
"@vue/compat": "3.2.22",
"bulma": "0.9.3",
"camel-case": "4.1.2",
"codemirror": "5.65.9",
"date-fns": "2.29.3",
"dompurify": "2.4.0",
"easymde": "2.18.0",
"flatpickr": "4.6.13",
"codemirror": "5.63.3",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.25.0",
"dompurify": "2.3.3",
"easymde": "2.15.0",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20",
"highlight.js": "11.6.0",
"highlight.js": "11.3.1",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.1.1",
"minimist": "1.2.7",
"pinia": "2.0.23",
"marked": "4.0.3",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "0.8.5",
"vue": "3.2.40",
"vue-advanced-cropper": "2.8.6",
"ufo": "0.7.9",
"vue": "3.2.22",
"vue-advanced-cropper": "2.7.0",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.8",
"vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
"vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.18",
"vue-router": "4.0.12",
"vuedraggable": "4.1.0",
"vuex": "4.0.2",
"workbox-precaching": "6.4.1"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "3.3.1",
"@cypress/vue": "4.2.0",
"@faker-js/faker": "7.5.0",
"@rushstack/eslint-patch": "1.2.0",
"@types/dompurify": "2.3.4",
"@types/flexsearch": "0.7.3",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.7",
"@types/node": "16.11.65",
"@typescript-eslint/eslint-plugin": "5.40.0",
"@typescript-eslint/parser": "5.40.0",
"@vitejs/plugin-legacy": "2.2.0",
"@vitejs/plugin-vue": "3.1.2",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.1.0",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.12",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001418",
"cypress": "10.10.0",
"esbuild": "0.15.10",
"eslint": "8.25.0",
"eslint-plugin-vue": "9.6.0",
"express": "4.18.2",
"happy-dom": "7.4.0",
"netlify-cli": "12.0.7",
"postcss": "8.4.17",
"postcss-preset-env": "7.8.2",
"rollup": "3.0.0",
"rollup-plugin-visualizer": "5.8.2",
"sass": "1.55.0",
"typescript": "4.8.4",
"vite": "3.1.7",
"vite-plugin-pwa": "0.13.1",
"vite-svg-loader": "3.6.0",
"vitest": "0.24.1",
"vue-tsc": "1.0.5",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
"@4tw/cypress-drag-drop": "2.0.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2",
"@types/jest": "27.0.2",
"@typescript-eslint/eslint-plugin": "5.4.0",
"@typescript-eslint/parser": "5.4.0",
"@vitejs/plugin-legacy": "1.6.2",
"@vitejs/plugin-vue": "1.9.4",
"@vue/eslint-config-typescript": "9.1.0",
"autoprefixer": "10.4.0",
"axios": "0.24.0",
"browserslist": "4.18.1",
"cypress": "9.0.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.13.14",
"eslint": "8.2.0",
"eslint-plugin-vue": "8.0.3",
"express": "4.17.1",
"faker": "5.5.3",
"jest": "27.3.1",
"netlify-cli": "6.14.25",
"postcss": "8.3.11",
"rollup": "2.60.0",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.43.4",
"slugify": "1.6.2",
"ts-jest": "27.0.7",
"typescript": "4.4.4",
"vite": "2.6.14",
"vite-plugin-pwa": "0.11.5",
"vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.5",
"wait-on": "6.0.0",
"workbox-cli": "6.4.1"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-essential",
"@vue/typescript"
],
"rules": {
"vue/html-quotes": [
"error",
"double"
],
"quotes": [
"error",
"single"
],
"comma-dangle": [
"error",
"always-multiline"
],
"semi": [
"error",
"never"
],
"vue/multi-word-component-names": 0
},
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2021
},
"ignorePatterns": [
"*.test.*",
"cypress/*"
]
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.13.4"
"jest": {
"testPathIgnorePatterns": [
"cypress"
],
"testEnvironment": "jsdom",
"preset": "ts-jest",
"roots": [
"<rootDir>/src"
],
"transform": {
"^.+\\.(js|tsx?)$": "ts-jest"
},
"moduleFileExtensions": [
"ts",
"js",
"json"
]
},
"license": "AGPL-3.0-or-later"
}

File diff suppressed because it is too large Load diff

View file

@ -3,28 +3,5 @@
"labels": ["dependencies"],
"extends": [
"config:base"
],
"packageRules": [
{
"matchPackageNames": ["netlify-cli", "happy-dom"],
"extends": ["schedule:weekly"]
},
{
"groupName": "caniuse-and-related",
"matchPackageNames": ["caniuse-lite", "browserslist"],
"extends": ["schedule:weekly"]
},
{
"groupName": "vueuse",
"matchPackagePrefixes": [
"@vueuse/"
]
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true,
"automergeStrategy": "squash",
"automergeType": "pr"
}
]
}

4
run.sh
View file

@ -4,7 +4,7 @@
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://7e684483a06a4225b3e05cc47cae7a11@sentry.kolaente.de/2"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
@ -16,7 +16,7 @@ VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_DSN = '.*'/\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'/g" /usr/share/nginx/html/index.html
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf

View file

@ -1,24 +1,20 @@
const slugify = require('slugify')
const {exec} = require('child_process')
const axios = require('axios')
const BOT_USER_ID = 513
const giteaToken = process.env.GITEA_TOKEN
const siteId = process.env.NETLIFY_SITE_ID
const branchSlug = String(process.env.DRONE_SOURCE_BRANCH)
.trim()
.normalize('NFKD')
.toLowerCase()
.replace(/[.\s/]/g, '-')
.replace(/[^A-Za-z\d-]/g, '')
const branchSlug = slugify(process.env.DRONE_SOURCE_BRANCH)
const prNumber = process.env.DRONE_PULL_REQUEST
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
const alias = `${prNumber}-${branchSlug}`.substring(0,37)
const alias = `${prNumber}-${branchSlug}`
const fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app`
const promiseExec = cmd => {
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout) => {
exec(cmd, (error, stdout, stderr) => {
if (error) {
reject(error)
return

View file

@ -1 +1 @@
bb46342a0a08105b340ba7976cff9d80ef89901120ec0639669caa70bb7d2dbc43e78b1f635a7654ab2456e8358c98a4 ./scripts/deploy-preview-netlify.js
55ce0faaa2c1919341617ccfaeccbb6029ac12107964ff488985cff13dd952f1a991df3ab0d4b0705deb761e508e6434 ./scripts/deploy-preview-netlify.js

View file

@ -3,7 +3,7 @@ const express = require('express')
const app = express()
const p = path.join(__dirname, '..', 'dist-dev')
const port = 4173
const port = 5000
app.use(express.static(p))
// Handle urls set by the frontend

View file

@ -1,92 +1,112 @@
<template>
<ready>
<template v-if="authUser">
<TheNavigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
<div :class="{'is-touch': isTouch}">
<div :class="{'is-hidden': !online}">
<template v-if="authUser">
<top-navigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<content-no-auth v-else/>
<notification/>
</div>
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
</div>
</ready>
</template>
<script lang="ts" setup>
import {computed, watch, type Ref} from 'vue'
import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useI18n} from 'vue-i18n'
<script>
import {defineComponent} from 'vue'
import {mapState, mapGetters} from 'vuex'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
import TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.vue'
import Notification from './components/misc/notification'
import {KEYBOARD_SHORTCUTS_ACTIVE, ONLINE} from './store/mutation-types'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts'
import TopNavigation from './components/home/topNavigation'
import ContentAuth from './components/home/contentAuth'
import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import Ready from '@/components/misc/ready'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import {useAuthStore} from './stores/auth'
export default defineComponent({
name: 'app',
components: {
ContentNoAuth,
ContentLinkShare,
ContentAuth,
TopNavigation,
KeyboardShortcuts,
Notification,
Ready,
},
beforeMount() {
this.setupOnlineStatus()
this.setupPasswortResetRedirect()
this.setupEmailVerificationRedirect()
this.setupAccountDeletionVerification()
},
beforeCreate() {
setLanguage()
},
created() {
// Make sure to always load the home route when running with electron
if (this.$route.fullPath.endsWith('frontend/index.html')) {
this.$router.push({name: 'home'})
}
},
computed: {
isTouch() {
return isTouchDevice()
},
...mapState({
online: ONLINE,
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
}),
...mapGetters('auth', [
'authUser',
'authLinkShare',
]),
},
methods: {
setupOnlineStatus() {
this.$store.commit(ONLINE, navigator.onLine)
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine))
window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine))
},
setupPasswortResetRedirect() {
if (typeof this.$route.query.userPasswordReset === 'undefined') {
return
}
const baseStore = useBaseStore()
const authStore = useAuthStore()
const router = useRouter()
localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
this.$router.push({name: 'user.password-reset.reset'})
},
setupEmailVerificationRedirect() {
if (typeof this.$route.query.userEmailConfirm === 'undefined') {
return
}
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
this.$router.push({name: 'user.login'})
},
async setupAccountDeletionVerification() {
if (typeof this.$route.query.accountDeletionConfirm === 'undefined') {
return
}
const authUser = computed(() => authStore.authUser)
const authLinkShare = computed(() => authStore.authLinkShare)
const {t} = useI18n({useScope: 'global'})
// setup account deletion verification
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
if (accountDeletionConfirm === null) {
return
}
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })
// setup password reset redirect
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === null) {
return
}
localStorage.setItem('passwordResetToken', userPasswordReset)
router.push({name: 'user.password-reset.reset'})
}, { immediate: true })
// setup email verification redirect
const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
watch(userEmailConfirm, (userEmailConfirm) => {
if (userEmailConfirm === null) {
return
}
localStorage.setItem('emailConfirmToken', userEmailConfirm)
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage()
useColorScheme()
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
this.$message.success({message: this.$t('user.deletion.confirmSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
},
},
})
</script>
<style lang="scss">

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

View file

@ -1,131 +0,0 @@
<template>
<component
:is="componentNodeName"
class="base-button"
:class="{ 'base-button--type-button': isButton }"
v-bind="elementBindings"
:disabled="disabled || undefined"
ref="button"
>
<slot/>
</component>
</template>
<script lang="ts">
export default { inheritAttrs: false }
</script>
<script lang="ts" setup>
// this component removes styling differences between links / vue-router links and button elements
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
// the component tries to heuristically determine what it should be checking the props (see the
// componentNodeName and elementBindings ref for this).
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
import { ref, watchEffect, computed, useAttrs, type PropType } from 'vue'
const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button',
submit: 'submit',
})
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
const props = defineProps({
type: {
type: String as PropType<BaseButtonTypes>,
default: 'button',
},
disabled: {
type: Boolean,
default: false,
},
})
const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings {
type?: string;
rel?: string;
target?: string;
}
const elementBindings = ref({})
const attrs = useAttrs()
watchEffect(() => {
// by default this component is a button element with the attribute of the type "button" (default prop value)
let nodeName = 'button'
let bindings: ElementBindings = {type: props.type}
// if we find a "to" prop we set it as router-link
if ('to' in attrs) {
nodeName = 'router-link'
bindings = {}
}
// if there is a href we assume the user wants an external link via a link element
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
if ('href' in attrs) {
nodeName = 'a'
bindings = {
rel: 'noreferrer noopener nofollow',
target: '_blank',
}
}
componentNodeName.value = nodeName
elementBindings.value = {
...bindings,
...attrs,
}
})
const isButton = computed(() => componentNodeName.value === 'button')
const button = ref()
function focus() {
button.value.focus()
}
defineExpose({
focus,
})
</script>
<style lang="scss">
// NOTE: we do not use scoped styles to reduce specifity and make it easy to overwrite
// We reset the default styles of a button element to enable easier styling
:where(.base-button--type-button) {
border: 0;
margin: 0;
padding: 0;
text-decoration: none;
background-color: transparent;
text-align: center;
appearance: none;
}
:where(.base-button) {
cursor: pointer;
display: inline-block;
color: inherit;
font: inherit;
user-select: none;
pointer-events: auto; // disable possible resets
&:focus, &.is-focused {
outline: transparent;
}
&[disabled] {
cursor: default;
}
}
</style>

View file

@ -1,21 +0,0 @@
export const DATE_RANGES = {
// Format:
// Key is the title, as a translation string, the first entry of the value array
// is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'],
'lastWeek': ['now/w-1w', 'now/w-2w'],
'thisWeek': ['now/w', 'now/w+1w'],
'restOfThisWeek': ['now', 'now/w+1w'],
'nextWeek': ['now/w+1w', 'now/w+2w'],
'next7Days': ['now', 'now+7d'],
'lastMonth': ['now/M-1M', 'now/M-2M'],
'thisMonth': ['now/M', 'now/M+1M'],
'restOfThisMonth': ['now', 'now/M+1M'],
'nextMonth': ['now/M+1M', 'now/M+2M'],
'next30Days': ['now', 'now+30d'],
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}

View file

@ -1,131 +0,0 @@
<template>
<card
class="has-no-shadow how-it-works-modal"
:title="$t('input.datemathHelp.title')">
<p>
{{ $t('input.datemathHelp.intro') }}
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression" scope="global">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar" scope="global">
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
Grafana
</BaseButton>
<BaseButton
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
target="_blank">
Elasticsearch
</BaseButton>
</i18n-t>
</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>
</ul>
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>s</code></td>
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
</tr>
<tr>
<td><code>m</code></td>
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
</tr>
<tr>
<td><code>h</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>H</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>d</code></td>
<td>{{ $t('input.datemathHelp.units.days') }}</td>
</tr>
<tr>
<td><code>w</code></td>
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
</tr>
<tr>
<td><code>M</code></td>
<td>{{ $t('input.datemathHelp.units.months') }}</td>
</tr>
<tr>
<td><code>y</code></td>
<td>{{ $t('input.datemathHelp.units.years') }}</td>
</tr>
</tbody>
</table>
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>now</code></td>
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
</tr>
<tr>
<td><code>now+24h</code></td>
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
</tr>
<tr>
<td><code>now/d</code></td>
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
</tr>
<tr>
<td><code>now/w</code></td>
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now/w+1w</code></td>
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now+30d</code></td>
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
</tr>
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
</tr>
</tbody>
</table>
</card>
</template>
<script lang="ts" setup>
import { formatDate } from '@/helpers/time/formatDate'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>
.how-it-works-modal {
font-size: 1rem;
}
p {
display: inline-block !important;
}
.base-button {
display: inline;
}
</style>

View file

@ -1,275 +0,0 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<template #trigger="{toggle}">
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
</template>
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div class="selections">
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_RANGES"
:key="text"
@click="setDateRange(value)"
:class="{'is-active': from === value[0] && to === value[1]}">
{{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.from') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="from"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<label class="label">
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="to"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<flat-pickr
:config="flatPickerConfig"
v-model="flatpickrRange"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
<modal
@close="() => showHowItWorks = false"
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
>
<DatemathHelp/>
</modal>
</div>
</div>
</template>
</popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
const {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
required: false,
},
})
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
}))
const showHowItWorks = ref(false)
const flatpickrRange = ref('')
const from = ref('')
const to = ref('')
watch(
() => props.modelValue,
newValue => {
from.value = newValue.dateFrom
to.value = newValue.dateTo
// Only set the date back to flatpickr when it's an actual date.
// Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = new Date(from.value)
const dateTo = new Date(to.value)
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
flatpickrRange.value = `${from.value} to ${to.value}`
}
},
)
function emitChanged() {
const args = {
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
}
emit('update:modelValue', args)
}
watch(
() => flatpickrRange.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
const [fromDate, toDate] = newVal.split(' to ')
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
from.value = fromDate
to.value = toDate
emitChanged()
},
)
watch(() => from.value, emitChanged)
watch(() => to.value, emitChanged)
function setDateRange(range: string[] | null) {
if (range === null) {
from.value = ''
to.value = ''
return
}
from.value = range[0]
to.value = range[1]
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
})
const buttonText = computed<string>(() => {
if (from.value !== '' && to.value !== '') {
return t('input.datepickerRange.fromto', {
from: from.value,
to: to.value,
})
}
return t('task.show.select')
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {
border: 1px solid var(--input-hover-border-color);
}
}
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>

View file

@ -1,20 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull)
</script>
<template>
<Logo alt="Vikunja" class="logo" />
</template>
<style lang="scss" scoped>
.logo {
color: var(--logo-text-color);
}
</style>
<Logo alt="Vikunja" />
</template>

View file

@ -1,22 +1,20 @@
<template>
<BaseButton
<button
type="button"
@click="$store.commit('toggleMenu')"
class="menu-show-button"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
@shortkey="() => $store.commit('toggleMenu')"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
/>
</template>
<script setup lang="ts">
<script setup>
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {store} from '@/store'
import BaseButton from '@/components/base/BaseButton.vue'
const baseStore = useBaseStore()
const menuActive = computed(() => baseStore.menuActive)
const menuActive = computed(() => store.menuActive)
</script>
<style lang="scss" scoped>
@ -24,6 +22,11 @@ $lineWidth: 2rem;
$size: $lineWidth + 1rem;
.menu-show-button {
// FIXME: create general button component
appearance: none;
background-color: transparent;
border: 0;
min-height: $size;
width: $size;
@ -40,7 +43,7 @@ $size: $lineWidth + 1rem;
width: $lineWidth;
left: 50%;
transform: $transformX;
background-color: var(--grey-400);
background-color: $grey-400;
border-radius: 2px;
transition: all $transition;
}
@ -59,7 +62,7 @@ $size: $lineWidth + 1rem;
&:focus {
&::before,
&::after {
background-color: var(--grey-600);
background-color: $grey-600;
}
&::before {

View file

@ -1,17 +1,16 @@
<template>
<BaseButton class="menu-bottom-link" :href="poweredByUrl" target="_blank">
<a class="menu-bottom-link" :href="poweredByUrl" target="_blank" rel="noreferrer noopener nofollow">
{{ $t('misc.poweredBy') }}
</BaseButton>
</a>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import {POWERED_BY as poweredByUrl} from '@/urls'
</script>
<style lang="scss">
.menu-bottom-link {
color: var(--grey-300);
color: $grey-300;
text-align: center;
display: block;
padding-top: 1rem;

View file

@ -1,124 +1,135 @@
<template>
<div class="content-auth">
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
</BaseButton>
<div>
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
<icon icon="times" />
</a>
<div
:class="{'has-background': background || blurHash}"
:style="{'background-image': blurHash && `url(${blurHash})`}"
:class="{'has-background': background}"
:style="{'background-image': background && `url(${background})`}"
class="app-container"
>
<navigation/>
<div
:class="{'is-visible': background}"
class="app-container-background background-fade-in d-print-none"
:style="{'background-image': background && `url(${background})`}"></div>
<navigation class="d-print-none"/>
<main
:class="[
{ 'is-menu-enabled': menuActive },
$route.name,
]"
class="app-content"
>
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
/>
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
<router-view/>
<router-view name="popup" v-slot="{ Component }">
<transition name="modal">
<component :is="Component" />
</transition>
</router-view>
<modal
v-if="currentModal"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal"/>
</modal>
<BaseButton
class="keyboard-shortcuts-button d-print-none"
<a
class="keyboard-shortcuts-button"
@click="showKeyboardShortcuts()"
v-shortcut="'?'"
>
<icon icon="keyboard"/>
</BaseButton>
</main>
</a>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {useRoute} from 'vue-router'
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
export default {
name: 'contentAuth',
components: {QuickActions, Navigation},
watch: {
'$route': {
handler: 'doStuffAfterRoute',
deep: true,
},
},
created() {
this.renewTokenOnFocus()
this.loadLabels()
},
computed: mapState({
background: 'background',
menuActive: MENU_ACTIVE,
userInfo: state => state.auth.info,
authenticated: state => state.auth.authenticated,
}),
methods: {
doStuffAfterRoute() {
// this.setTitle('') // Reset the title if the page component does not set one itself
this.hideMenuOnMobile()
this.resetCurrentList()
},
resetCurrentList() {
// Reset the current list highlight in menu if the current list is not list related.
if (
this.$route.name === 'home' ||
this.$route.name === 'namespace.edit' ||
this.$route.name === 'teams.index' ||
this.$route.name === 'teams.edit' ||
this.$route.name === 'tasks.range' ||
this.$route.name === 'labels.index' ||
this.$route.name === 'migrate.start' ||
this.$route.name === 'migrate.wunderlist' ||
this.$route.name === 'user.settings' ||
this.$route.name === 'namespaces.index'
) {
return this.$store.dispatch(CURRENT_LIST, null)
}
},
renewTokenOnFocus() {
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
this.$store.dispatch('auth/renewToken')
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
// Check if the token is still valid if the window gets focus again to maybe renew it
window.addEventListener('focus', () => {
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
if (!this.authenticated) {
return
}
const baseStore = useBaseStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive)
const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000
function showKeyboardShortcuts() {
baseStore.setKeyboardShortcutsActive(true)
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
this.$store.dispatch('auth/checkAuth')
this.$router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
this.$store.dispatch('auth/renewToken')
console.debug('renewed token')
}
})
},
hideMenuOnMobile() {
if (window.innerWidth < 769) {
this.$store.commit(MENU_ACTIVE, false)
}
},
showKeyboardShortcuts() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
},
loadLabels() {
this.$store.dispatch('labels/loadAllLabels')
},
},
}
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
// FIXME: this is really error prone
// 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',
'namespaces.index',
].includes(routeName) ||
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentList({list: null})
}
})
// TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>
<style lang="scss" scoped>
@ -133,7 +144,7 @@ labelStore.loadAllLabels()
justify-content: center;
align-items: center;
font-size: 2rem;
color: var(--grey-400);
color: $grey-400;
line-height: 1;
transition: all $transition;
@ -144,52 +155,45 @@ labelStore.loadAllLabels()
&:hover,
&:focus {
height: 1rem;
color: var(--grey-600);
color: $grey-600;
}
}
.app-container {
min-height: calc(100vh - 65px);
min-height: calc(100vh - 65px);
@media screen and (max-width: $tablet) {
padding-top: $navbar-height;
@media screen and (max-width: $tablet) {
padding-top: $navbar-height;
}
.app-content {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
z-index: 2;
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
&.task\.detail {
padding-left: 0;
padding-right: 0;
}
.app-content {
z-index: 10;
position: relative;
padding-top: 1rem;
@media screen {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
}
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
@media screen {
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
}
.card {
background: var(--white);
}
}
.card {
background: $white;
}
}
}
.mobile-overlay {
@ -199,9 +203,7 @@ labelStore.loadAllLabels()
bottom: 0;
left: 0;
right: 0;
height: 100vh;
width: 100vw;
background: hsla(var(--grey-100-hsl), 0.8);
background: rgba(250, 250, 250, 0.8);
z-index: 5;
opacity: 0;
transition: all $transition;
@ -218,23 +220,11 @@ labelStore.loadAllLabels()
right: 1rem;
z-index: 4500; // The modal has a z-index of 4000
color: var(--grey-500);
color: $grey-500;
transition: color $transition;
@media screen and (max-width: $tablet) {
display: none;
}
}
.content-auth {
position: relative;
z-index: 1;
}
.is-touch .content-auth,
.content-auth.z-unset {
z-index: unset;
}
@include modal-transition();
</style>

View file

@ -1,52 +1,55 @@
<template>
<div
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
:class="[background ? 'has-background' : '', $route.name+'-view']"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<Logo class="logo" v-if="logoVisible"/>
<Logo class="logo" />
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<router-view/>
<PoweredByLink/>
<PoweredByLink />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
<script>
import {mapState} from 'vuex'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
export default {
name: 'contentLinkShare',
components: {
Logo,
PoweredByLink,
},
computed: mapState([
'currentList',
'background',
]),
}
</script>
<style lang="scss" scoped>
.link-share-container.has-background .view {
background-color: transparent;
border: none;
background-color: transparent;
border: none;
}
.logo {
max-width: 300px;
width: 90%;
margin: 2rem 0 1.5rem;
height: 100px;
}
.column {
@ -54,11 +57,11 @@ const logoVisible = computed(() => baseStore.logoVisible)
}
.title {
text-shadow: 0 0 1rem var(--white);
text-shadow: 0 0 1rem $white;
}
// FIXME: this should be defined somewhere deep
.link-share-view .card {
background-color: var(--white);
background-color: $white;
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<no-auth-wrapper>
<router-view/>
</no-auth-wrapper>
</template>
<script>
import {saveLastVisited} from '@/helpers/saveLastVisited'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
export default {
name: 'contentNoAuth',
components: {NoAuthWrapper},
computed: {
routeName() {
return this.$route.name
},
},
watch: {
routeName: {
handler(routeName) {
if (!routeName) return
this.redirectToHome()
},
immediate: true,
},
},
methods: {
redirectToHome() {
// Check if the user is already logged in and redirect them to the home page if not
if (
this.$route.name !== 'user.login' &&
this.$route.name !== 'user.password-reset.request' &&
this.$route.name !== 'user.password-reset.reset' &&
this.$route.name !== 'user.register' &&
this.$route.name !== 'link-share.auth' &&
this.$route.name !== 'openid.auth' &&
localStorage.getItem('passwordResetToken') === null &&
localStorage.getItem('emailConfirmToken') === null
) {
saveLastVisited(this.$route.name, this.$route.params)
this.$router.push({name: 'user.login'})
}
},
},
}
</script>

View file

@ -1,12 +1,12 @@
<template>
<aside :class="{'is-active': menuActive}" class="namespace-container">
<nav class="menu top-menu">
<div :class="{'is-active': menuActive}" class="namespace-container">
<div class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/>
<Logo width="164" height="48" />
</router-link>
<ul class="menu-list">
<li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<router-link :to="{ name: 'home'}">
<span class="icon">
<icon icon="calendar"/>
</span>
@ -14,7 +14,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
<router-link :to="{ name: 'tasks.range'}">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
@ -22,7 +22,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
<router-link :to="{ name: 'namespaces.index'}">
<span class="icon">
<icon icon="layer-group"/>
</span>
@ -30,7 +30,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
<router-link :to="{ name: 'labels.index'}">
<span class="icon">
<icon icon="tags"/>
</span>
@ -38,7 +38,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
<router-link :to="{ name: 'teams.index'}">
<span class="icon">
<icon icon="users"/>
</span>
@ -46,54 +46,56 @@
</router-link>
</li>
</ul>
</nav>
</div>
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id">
<aside 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
<span
@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 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] }})
v-tooltip="namespaceTitles[nk]">
<span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ namespaceTitles[nk] }}
</span>
</BaseButton>
</span>
<a
class="icon is-small toggle-lists-icon"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<div
v-if="listsVisible[n.id] ?? true"
:key="n.id + 'child'"
class="more-container"
>
<!--
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"
:group="`namespace-${n.id}-lists`"
@start="() => drag = true"
@end="saveListPosition"
@end="e => saveListPosition(e, nk)"
handle=".handle"
:disabled="n.id < 0 || undefined"
tag="ul"
:disabled="n.id < 0 || null"
tag="transition-group"
item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{
type: 'transition-group',
type: 'transition',
tag: 'ul',
name: !drag ? 'flip-list' : null,
class: [
'menu-list can-be-hidden',
@ -103,184 +105,187 @@
>
<template #item="{element: l}">
<li
class="list-menu loader-container is-loading-small"
class="loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}"
>
<BaseButton
<router-link
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}"
v-slot="{ href, navigate, isActive }"
custom
>
<span class="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>
<a
@click="navigate"
:href="href"
class="list-menu-link"
:class="{'router-link-exact-active': isActive || currentList?.id === l.id}"
>
<span class="icon handle">
<icon icon="grip-lines"/>
</span>
<span
:style="{ backgroundColor: l.hexColor }"
class="color-bubble"
v-if="l.hexColor !== ''">
</span>
<span class="list-menu-title">
{{ getListTitle(l) }}
</span>
<span
:class="{'is-favorite': l.isFavorite}"
@click.prevent.stop="toggleFavoriteList(l)"
class="favorite">
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']" />
</span>
</a>
</router-link>
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</draggable>
</div>
</template>
</nav>
<PoweredByLink/>
</aside>
</aside>
<PoweredByLink />
</div>
</template>
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
<script>
import {mapState} from 'vuex'
import draggable from 'vuedraggable'
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 {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
import {useEventListener} from '@vueuse/core'
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 {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false)
const dragOptions = {
animation: 100,
ghostClass: 'ghost',
}
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
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
})
})
})
export default {
name: 'navigation',
const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
components: {
ListSettingsDropdown,
NamespaceSettingsDropdown,
draggable,
Logo,
PoweredByLink,
},
const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
})
useEventListener('resize', resize)
onMounted(() => resize())
const listStore = useListStore()
function resize() {
// Hide the menu by default on mobile
baseStore.setMenuActive(window.innerWidth >= 770)
}
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
data() {
return {
listsVisible: {},
drag: false,
dragOptions: {
animation: 100,
ghostClass: 'ghost',
},
listUpdating: {},
}
})
})
},
computed: {
...mapState({
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
}),
activeLists() {
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived))
},
namespaceTitles() {
return this.namespaces.map((namespace, index) => {
const title = this.getNamespaceTitle(namespace)
return `${title} (${this.activeLists[index]?.length ?? 0})`
})
},
},
beforeCreate() {
// FIXME: async action in beforeCreate, might be unfinished when component mounts
this.$store.dispatch('namespaces/loadNamespaces')
.then(namespaces => {
namespaces.forEach(n => {
if (typeof this.listsVisible[n.id] === 'undefined') {
this.listsVisible[n.id] = true
}
})
})
},
created() {
window.addEventListener('resize', this.resize)
},
mounted() {
this.resize()
},
methods: {
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
},
resize() {
// Hide the menu by default on mobile
this.$store.commit(MENU_ACTIVE, window.innerWidth >= 770)
},
toggleLists(namespaceId) {
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
},
updateActiveLists(namespace, activeLists) {
// 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.
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order
const lists = namespace.lists.map((item) => {
if (item.isArchived) {
return item
}
return activeLists.shift()
})
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),
]
const newNamespace = {
...namespace,
lists,
}
namespaceStore.setNamespaceById({
...namespace,
lists,
})
}
this.$store.commit('namespaces/setNamespaceById', newNamespace)
},
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async saveListPosition(e, namespaceIndex) {
const listsActive = this.activeLists[namespaceIndex]
const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null
this.listUpdating[list.id] = true
async function saveListPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
const position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
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
}
try {
// create a copy of the list in order to not violate vuex mutations
await this.$store.dispatch('lists/updateList', {
...list,
position,
})
} finally {
this.listUpdating[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-background: $light-background;
$vikunja-nav-color: $grey-700;
$vikunja-nav-selected-width: 0.4rem;
.namespace-container {
z-index: 6;
background: $vikunja-nav-background;
color: $vikunja-nav-color;
padding: 0 0 1rem;
@ -293,10 +298,10 @@ $vikunja-nav-selected-width: 0.4rem;
overflow-x: auto;
width: $navbar-width;
@media screen and (max-width: $tablet) {
top: 0;
width: 70vw;
z-index: 20;
}
&.is-active {
@ -320,7 +325,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.menu-label,
.menu-list .list-menu-link,
.menu-list span.list-menu-link,
.menu-list a {
display: flex;
align-items: center;
@ -338,28 +343,30 @@ $vikunja-nav-selected-width: 0.4rem;
flex: 0 0 12px;
}
}
.favorite {
margin-left: .25rem;
transition: opacity $transition, color $transition;
opacity: 0;
.favorite {
margin-left: .25rem;
transition: opacity $transition, color $transition;
opacity: 0;
&:hover,
&.is-favorite {
color: var(--warning);
&:hover {
color: $orange;
}
&.is-favorite {
opacity: 1;
color: $orange;
}
}
}
.favorite.is-favorite,
.list-menu:hover .favorite {
opacity: 1;
&:hover .favorite {
opacity: 1;
}
}
.menu-label {
.color-bubble {
width: 14px;
height: 14px;
flex-basis: auto;
width: 14px !important;
height: 14px !important;
}
.is-archived {
@ -371,8 +378,6 @@ $vikunja-nav-selected-width: 0.4rem;
display: flex;
align-items: center;
justify-content: space-between;
color: $vikunja-nav-color;
padding: 0 .25rem;
.menu-label {
margin-bottom: 0;
@ -382,13 +387,12 @@ $vikunja-nav-selected-width: 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
}
.count {
color: var(--grey-500);
margin-right: .5rem;
}
a:not(.dropdown-item) {
color: $vikunja-nav-color;
padding: 0 .25rem;
}
:deep(.dropdown-trigger) {
@ -420,7 +424,7 @@ $vikunja-nav-selected-width: 0.4rem;
.menu-label,
.nsettings,
.menu-list .list-menu-link,
.menu-list span.list-menu-link,
.menu-list a {
color: $vikunja-nav-color;
}
@ -432,7 +436,7 @@ $vikunja-nav-selected-width: 0.4rem;
align-items: center;
&:hover {
background: var(--white);
background: $white;
}
:deep(.dropdown-trigger) {
@ -452,7 +456,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.ghost {
background: var(--grey-200);
background: $grey-200;
* {
opacity: 0;
@ -463,7 +467,7 @@ $vikunja-nav-selected-width: 0.4rem;
background: transparent;
}
.list-menu-link, li > a {
span.list-menu-link, li > a {
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease;
@ -478,7 +482,7 @@ $vikunja-nav-selected-width: 0.4rem;
height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
&.handle {
opacity: 0;
transition: opacity $transition;
@ -486,22 +490,22 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: grab;
}
}
&:hover .icon.handle {
opacity: 1;
}
&.router-link-exact-active {
color: var(--primary);
border-left: $vikunja-nav-selected-width solid var(--primary);
color: $primary;
border-left: $vikunja-nav-selected-width solid $primary;
.icon {
color: var(--primary);
color: $primary;
}
}
&:hover {
border-left: $vikunja-nav-selected-width solid var(--primary);
border-left: $vikunja-nav-selected-width solid $primary;
}
}
}
@ -509,9 +513,8 @@ $vikunja-nav-selected-width: 0.4rem;
.logo {
display: block;
padding-left: 1rem;
padding-left: 2rem;
margin-right: 1rem;
margin-bottom: 1rem;
@media screen and (min-width: $tablet) {
display: none;
@ -523,7 +526,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.icon {
color: var(--grey-400) !important;
color: $grey-400 !important;
}
}
@ -536,10 +539,10 @@ $vikunja-nav-selected-width: 0.4rem;
font-family: $vikunja-font;
}
.list-menu-link, li > a {
span.list-menu-link, li > a {
padding-left: 2rem;
display: inline-block;
.icon {
padding-bottom: .25rem;
}
@ -549,11 +552,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.list-setting-spacer {
width: 2.5rem;
width: 32px;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
</style>

View file

@ -1,10 +1,11 @@
<template>
<header
:class="{'has-background': background, 'menu-active': menuActive}"
<nav
:class="{'has-background': background}"
aria-label="main navigation"
class="navbar main-theme is-fixed-top d-print-none"
class="navbar main-theme is-fixed-top"
role="navigation"
>
<router-link :to="{name: 'home'}" class="logo-link">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button"/>
@ -16,35 +17,28 @@
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</template>
</div>
<div class="navbar-end">
<update/>
<BaseButton
<a
@click="openQuickActions"
class="trigger-button pr-0"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger="{toggleOpen}">
<template #trigger>
<x-button
class="username-dropdown-trigger"
@click="toggleOpen()"
variant="secondary"
:shadow="false"
>
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
type="secondary"
:shadow="false">
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
@ -52,109 +46,104 @@
</x-button>
</template>
<dropdown-item
:to="{name: 'user.settings'}"
>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item
v-if="imprintUrl"
</router-link>
<a
:href="imprintUrl"
>
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item
v-if="privacyPolicyUrl"
</a>
<a
:href="privacyPolicyUrl"
>
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="baseStore.setKeyboardShortcutsActive(true)"
>
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item
:to="{name: 'about'}"
>
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item
@click="logout()"
>
</router-link>
<a @click="logout()" class="dropdown-item">
{{ $t('user.auth.logout') }}
</dropdown-item>
</a>
</dropdown>
</div>
</div>
</header>
</nav>
</template>
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {RIGHTS as Rights} from '@/constants/rights'
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/constants/rights.json'
import Update from '@/components/home/update.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'
import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import {getListTitle} from '@/helpers/getListTitle'
export default {
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,
Logo,
MenuButton,
},
computed: {
...mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,
userAuthenticated: state => state.auth.authenticated,
currentList: CURRENT_LIST,
background: 'background',
imprintUrl: state => state.config.legal.imprintUrl,
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
}),
},
mounted() {
this.$nextTick(() => {
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
return
}
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const userAvatar = computed(() => authStore.avatarUrl)
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const usernameDropdown = ref()
const listTitle = ref()
onMounted(async () => {
await nextTick()
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
function logout() {
authStore.logout()
}
function openQuickActions() {
baseStore.setQuickActionsActive(true)
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
},
methods: {
logout() {
this.$store.dispatch('auth/logout')
this.$router.push({name: 'user.login'})
},
openQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
},
},
}
</script>
<style lang="scss" scoped>
$vikunja-nav-logo-full-width: 164px;
$user-dropdown-width-mobile: 5rem;
$hamburger-menu-icon-spacing: 1rem;
$hamburger-menu-icon-width: 28px;
.navbar {
z-index: 4 !important;
}
.logo-link {
.logo {
display: none;
padding: 0.5rem 0.75rem;
@media screen and (min-width: $tablet) {
align-self: stretch;
@ -175,7 +164,8 @@ $hamburger-menu-icon-width: 28px;
}
.navbar.main-theme {
background: var(--site-background);
background: $light-background;
z-index: 5 !important;
justify-content: space-between;
align-items: center;
@ -199,20 +189,21 @@ $hamburger-menu-icon-width: 28px;
}
@media screen and (max-width: $tablet) {
&.menu-active {
z-index: 0;
}
.user {
width: $user-dropdown-width-mobile;
display: flex;
align-items: center;
.username-dropdown-trigger {
:deep(.dropdown-trigger) {
line-height: 1;
padding: 0 0.25rem;
height: 1rem;
.icon {
width: .5rem;
.button {
padding: 0 0.25rem;
height: 1rem;
.icon {
width: .5rem;
}
}
}
@ -228,7 +219,7 @@ $hamburger-menu-icon-width: 28px;
:deep() {
.trigger-button {
cursor: pointer;
color: var(--grey-400);
color: $grey-400;
padding: .5rem;
font-size: 1.25rem;
position: relative;
@ -251,10 +242,9 @@ $hamburger-menu-icon-width: 28px;
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
}
.username-dropdown-trigger {
:deep(.dropdown-trigger .button) {
background: none;
&:focus:not(:active), &:active {
@ -288,22 +278,11 @@ $hamburger-menu-icon-width: 28px;
}
:deep(.dropdown-trigger) {
color: var(--grey-400);
margin-left: .5rem;
color: $grey-400;
margin-left: 1rem;
height: 1rem;
width: 1rem;
cursor: pointer;
}
}
.info-button {
text-align: center;
height: 1.25rem;
line-height: 1.25rem;
width: 2rem;
margin-top: .25rem;
padding: 0 .5rem;
color: var(--grey-400);
margin-left: .5rem;
}
</style>

View file

@ -1,61 +1,72 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p>{{ $t('update.available') }}</p>
<x-button @click="refreshApp()" :shadow="false" class="has-no-text-wrap">
<x-button @click="refreshApp()" :shadow="false">
{{ $t('update.do') }}
</x-button>
</div>
</template>
<script lang="ts" setup>
import {ref} from 'vue'
<script>
export default {
name: 'update',
data() {
return {
updateAvailable: false,
registration: null,
refreshing: false,
}
},
created() {
document.addEventListener('swUpdated', this.showRefreshUI, {once: true})
const updateAvailable = ref(false)
const registration = ref(null)
const refreshing = ref(false)
document.addEventListener('swUpdated', showRefreshUI, {once: true})
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (refreshing.value) return
refreshing.value = true
window.location.reload()
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.refreshing) return
this.refreshing = true
window.location.reload()
},
)
}
},
methods: {
showRefreshUI(e) {
console.log('recieved refresh event', e)
this.registration = e.detail
this.updateAvailable = true
},
)
}
function showRefreshUI(e) {
console.log('recieved refresh event', e)
registration.value = e.detail
updateAvailable.value = true
}
function refreshApp() {
if (!registration.value || !registration.value.waiting) {
return
}
// Notify the service worker to actually do the update
registration.value.waiting.postMessage('skipWaiting')
refreshApp() {
this.updateExists = false
if (!this.registration || !this.registration.waiting) {
return
}
// Notify the service worker to actually do the update
this.registration.waiting.postMessage('skipWaiting')
},
},
}
</script>
<style lang="scss" scoped>
.update-notification {
margin: 1rem;
display: flex;
align-items: center;
background: $warning;
padding: .5rem;
padding: 0 0 0 .5rem;
border-radius: $radius;
font-size: .9rem;
color: var(--grey-900);
color: $grey-900;
justify-content: space-between;
position: fixed;
bottom: 1rem;
width: 450px;
left: calc(50vw - 225px);
@media screen and (max-width: $desktop) {
position: fixed;
bottom: 1rem;
margin: 0;
width: 450px;
left: calc(50vw - 225px);
}
@media screen and (max-width: $tablet) {
position: fixed;
@ -74,8 +85,4 @@ function refreshApp() {
margin-left: .5rem;
}
}
.dark .update-notification {
color: var(--grey-200);
}
</style>

View file

@ -1,72 +1,77 @@
<template>
<BaseButton
<a
class="button"
:class="[
variantClass,
{
'is-loading': loading,
'has-no-shadow': !shadow || variant === 'tertiary',
}
]"
:class="{
'is-loading': loading,
'has-no-shadow': !shadow,
'is-primary': type === 'primary',
'is-outlined': type === 'secondary',
'is-text is-inverted has-no-shadow underline-none':
type === 'tertary',
}"
:disabled="disabled || null"
@click="click"
:href="href !== '' ? href : null"
>
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<icon :icon="icon" v-if="showIconOnly"/>
<span class="icon is-small" v-else-if="icon !== ''">
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<icon :icon="icon"/>
</span>
<slot />
</BaseButton>
<slot></slot>
</a>
</template>
<script lang="ts">
export default { name: 'x-button' }
</script>
<script setup lang="ts">
import {computed, useSlots, type PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const BUTTON_TYPES_MAP = Object.freeze({
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
})
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
const props = defineProps({
variant: {
type: String as PropType<ButtonTypes>,
default: 'primary',
<script>
export default {
name: 'x-button',
props: {
type: {
type: String,
default: 'primary',
},
href: {
type: String,
default: '',
},
to: {
default: false,
},
icon: {
default: '',
},
loading: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
},
icon: {
type: [String, Array],
default: '',
emits: ['click'],
computed: {
showIconOnly() {
return this.icon !== '' && typeof this.$slots.default === 'undefined'
},
},
iconColor: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: true,
},
})
methods: {
click(e) {
if (this.disabled) {
return
}
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
if (this.to !== false) {
this.$router.push(this.to)
}
const slots = useSlots()
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
this.$emit('click', e)
},
},
}
</script>
<style lang="scss" scoped>
@ -76,14 +81,12 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: break-spaces;
height: $button-height;
box-shadow: $shadow-sm;
&.is-hovered,
&:hover {
box-shadow: var(--shadow-md);
box-shadow: $shadow-md;
}
&.fullheight {
@ -96,17 +99,16 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
box-shadow: $shadow-xs !important;
}
&.is-primary.is-outlined:hover {
color: var(--white);
color: $white;
}
}
.is-small {
border-radius: $radius;
&.is-small {
border-radius: $radius;
}
}
.underline-none {

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