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: # AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key # from_secret: cache_aws_secret_access_key
# settings: # settings:
# debug: true
# restore: true # restore: true
# bucket: kolaente.dev-drone-dependency-cache # bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: dependencies - name: dependencies
image: node:18-alpine image: node:16
pull: true pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress CYPRESS_CACHE_FOLDER: .cache/cypress/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000
# depends_on: # depends_on:
# - restore-cache # - 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 # - name: rebuild-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: true # pull: true
@ -128,14 +63,82 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
# depends_on: # depends_on:
# - dependencies # - 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 - name: deploy-preview
image: node:18-alpine image: node:16
pull: true pull: true
environment: environment:
NETLIFY_AUTH_TOKEN: NETLIFY_AUTH_TOKEN:
@ -145,10 +148,6 @@ steps:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
commands: 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 - shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js - node ./scripts/deploy-preview-netlify.js
depends_on: depends_on:
@ -191,22 +190,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: build - name: build
image: node:18-alpine image: node:16
pull: true pull: true
group: build-static group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/.pnp - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000 - yarn run lint
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json" - "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 - sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on: # depends_on:
# - restore-cache # - restore-cache
@ -267,22 +265,21 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud # endpoint: https://s3.fr-par.scw.cloud
# region: fr-par # region: fr-par
# path_style: true # path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}' # cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount: # mount:
# - .cache # - '.cache'
- name: build - name: build
image: node:18-alpine image: node:16
pull: true pull: true
group: build-static group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm YARN_CACHE_FOLDER: .cache/yarn/
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - yarn --frozen-lockfile --network-timeout 100000
- pnpm install --fetch-timeout 100000 - yarn run lint
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json" - "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 - sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on: # depends_on:
# - restore-cache # - restore-cache
@ -659,6 +656,6 @@ steps:
from_secret: crowdin_key from_secret: crowdin_key
--- ---
kind: signature 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 node_modules
/dist* /dist*
*.zip *.zip
.direnv/
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local
# Log files # Log files
logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
stats.html stats.html
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files # Editor directories and files
.idea .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", "codezombiech.gitignore",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"editorconfig.editorconfig", "editorconfig.editorconfig",
"vue.volar", "johnsoncodehk.volar",
"vue.vscode-typescript-vue-plugin",
"lokalise.i18n-ally", "lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel", "mgmcdermott.vscode-language-babel",
"mikestead.dotenv", "mikestead.dotenv",

View file

@ -1,5 +1,5 @@
{ {
"eslint.packageManager": "pnpm", "eslint.packageManager": "yarn",
"editor.formatOnSave": false, "editor.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true "source.fixAll": true
@ -18,11 +18,13 @@
"javascriptreact", "javascriptreact",
"vue" "vue"
], ],
"vetur.validation.template": false,
// i18n ally // i18n ally
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/i18n/lang" "src/i18n/lang"
], ],
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": 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 # Stage 1: Build application
FROM node:18-alpine AS compile-image FROM node:16 AS compile-image
WORKDIR /build WORKDIR /build
ARG USE_RELEASE=false ARG USE_RELEASE=false
ARG RELEASE_VERSION=main ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/ ENV YARN_CACHE_FOLDER .cache/yarn/
ADD . ./ COPY . ./
RUN \ RUN \
if [ $USE_RELEASE = true ]; then \ if [ $USE_RELEASE = true ]; then \
rm -rf dist/ && \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \ wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \ unzip frontend-release.zip -d dist/ && \
exit 0; \ exit 0; \
fi && \ 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 # Build the frontend
pnpm install && \ yarn install --frozen-lockfile --network-timeout 100000 && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \ 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 # Stage 2: copy
FROM nginx:alpine FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh COPY run.sh /run.sh
@ -40,10 +36,4 @@ ENV PGID 1000
LABEL maintainer="maintainers@vikunja.io" LABEL maintainer="maintainers@vikunja.io"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
CMD "/run.sh" 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) [![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) [![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) [![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js. 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 ## Project setup
```shell ```shell
pnpm install yarn install
``` ```
### Compiles and hot-reloads for development ### Compiles and hot-reloads for development
```shell ```shell
pnpm run serve yarn run serve
``` ```
### Compiles and minifies for production ### Compiles and minifies for production
```shell ```shell
pnpm run build yarn run build
``` ```
### Lints and fixes files ### Lints and fixes files
```shell ```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 In that shell you can then execute the tests with
```shell ```shell
pnpm run test:frontend yarn test:frontend
``` ```
### Using The Cypress Dashboard ### Using The Cypress Dashboard
@ -44,5 +44,5 @@ pnpm run test:frontend
To open the Cypress Dashboard and run tests from there, run To open the Cypress Dashboard and run tests from there, run
```shell ```shell
pnpm run cypress:open yarn cypress:open
``` ```

View file

@ -9,7 +9,7 @@ services:
ports: ports:
- 3456:3456 - 3456:3456
cypress: cypress:
image: cypress/browsers:node16.14.0-chrome99-ff97 image: cypress/browsers:node12.18.3-chrome87-ff82
volumes: volumes:
- ..:/project - ..:/project
- $HOME/.cache:/home/node/.cache/ - $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 {Factory} from '../support/factory'
import {formatISO} from 'date-fns' 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 {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View file

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

View file

@ -1,6 +1,6 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
import {faker} from '@faker-js/faker' import faker from 'faker'
export class ListFactory extends Factory { export class ListFactory extends Factory {
static table = 'lists' 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 {Factory} from '../support/factory'
import {formatISO} from 'date-fns' 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 {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'
@ -15,7 +15,6 @@ export class TaskFactory extends Factory {
list_id: 1, list_id: 1,
created_by_id: 1, created_by_id: 1,
index: '{increment}', index: '{increment}',
position: '{increment}',
created: formatISO(now), created: formatISO(now),
updated: 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 {Factory} from '../support/factory'
import {formatISO} from "date-fns" 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 {Factory} from '../support/factory'
import {formatISO} from 'date-fns' 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 {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
@ -14,7 +14,6 @@ export class UserFactory extends Factory {
username: faker.lorem.word(10) + faker.datatype.uuid(), username: faker.lorem.word(10) + faker.datatype.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234 password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0, status: 0,
issuer: 'local',
created: formatISO(now), created: formatISO(now),
updated: 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', () => { it('Should be all there', () => {
cy.visit('/namespaces') cy.visit('/namespaces')
cy.get('[data-cy="namespace-title"]') cy.get('.namespace h1 span')
.should('contain', namespaces[0].title) .should('contain', namespaces[0].title)
}) })
@ -23,14 +23,14 @@ describe('Namepaces', () => {
const newNamespaceTitle = 'New Namespace' const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces') cy.visit('/namespaces')
cy.get('[data-cy="new-namespace"]') cy.get('a.button')
.should('contain', 'New namespace') .contains('Create a new namespace')
.click() .click()
cy.url() cy.url()
.should('contain', '/namespaces/new') .should('contain', '/namespaces/new')
cy.get('.card-header-title') cy.get('.card-header-title')
.should('contain', 'New namespace') .should('contain', 'Create a new namespace')
cy.get('input.input') cy.get('input.input')
.type(newNamespaceTitle) .type(newNamespaceTitle)
cy.get('.button') cy.get('.button')
@ -63,7 +63,7 @@ describe('Namepaces', () => {
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded .should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext') cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`) .type(`{selectall}${newNamespaceName}`)
cy.get('footer.card-footer .button') cy.get('footer.modal-card-foot .button')
.contains('Save') .contains('Save')
.click() .click()
@ -72,7 +72,7 @@ describe('Namepaces', () => {
cy.get('.namespace-container .menu.namespaces-lists') cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName) .should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title) .should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]') cy.get('.content.namespaces-list')
.should('contain', newNamespaceName) .should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title) .should('not.contain', newNamespaces[0].title)
}) })
@ -89,7 +89,7 @@ describe('Namepaces', () => {
.click() .click()
cy.url() cy.url()
.should('contain', '/settings/delete') .should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]') cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it') .contains('Do it')
.click() .click()
@ -116,30 +116,30 @@ describe('Namepaces', () => {
// Initial // Initial
cy.visit('/namespaces') cy.visit('/namespaces')
cy.get('.namespace') cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived') .should('not.contain', 'Archived')
// Show 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') .should('be.visible')
.click() .click()
cy.get('[data-cy="show-archived-check"] input') cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('be.checked') .should('be.checked')
cy.get('.namespace') cy.get('.namespaces-list .namespace')
.should('contain', 'Archived') .should('contain', 'Archived')
// Don't show 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') .should('be.visible')
.click() .click()
cy.get('[data-cy="show-archived-check"] input') cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked') .should('not.be.checked')
// Second time visiting after unchecking // Second time visiting after unchecking
cy.visit('/namespaces') cy.visit('/namespaces')
cy.get('[data-cy="show-archived-check"] input') cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked') .should('not.be.checked')
cy.get('.namespace') cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived') .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 {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace' import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list' import {UserListFactory} from '../../factories/users_list'
import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee' import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels' import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task' import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket' 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', () => { describe('Task', () => {
let namespaces let namespaces
let lists let lists
let buckets
beforeEach(() => { beforeEach(() => {
UserFactory.create(1) UserFactory.create(1)
namespaces = NamespaceFactory.create(1) namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1) lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
})
TaskFactory.truncate() TaskFactory.truncate()
UserListFactory.truncate() UserListFactory.truncate()
}) })
@ -116,7 +80,6 @@ describe('Task', () => {
describe('Task Detail View', () => { describe('Task Detail View', () => {
beforeEach(() => { beforeEach(() => {
TaskCommentFactory.truncate() TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
}) })
it('Shows all task details', () => { it('Shows all task details', () => {
@ -153,7 +116,6 @@ describe('Task', () => {
.should('be.visible') .should('be.visible')
.should('contain', 'Done') .should('contain', 'Done')
cy.get('.task-view .action-buttons p.created') cy.get('.task-view .action-buttons p.created')
.scrollIntoView()
.should('be.visible') .should('be.visible')
.should('contain', 'Done') .should('contain', 'Done')
}) })
@ -166,7 +128,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Mark task done!') .contains('Done!')
.click() .click()
cy.get('.task-view .heading .is-done') cy.get('.task-view .heading .is-done')
@ -202,11 +164,11 @@ describe('Task', () => {
}) })
cy.visit(`/tasks/${tasks[0].id}`) 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() .click()
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll') cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description') .type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]') cy.get('.task-view .details.content.description .editor a')
.contains('Save') .contains('Save')
.click() .click()
@ -247,7 +209,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Move') .contains('Move task')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`) .type(`${lists[1].title}{enter}`)
@ -274,7 +236,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.should('be.visible') .should('be.visible')
.contains('Delete') .contains('Delete task')
.click() .click()
cy.get('.modal-mask .modal-container .modal-content .header') cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task') .should('contain', 'Delete this task')
@ -301,7 +263,8 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) 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() .click()
cy.get('.task-view .column.assignees .multiselect input') cy.get('.task-view .column.assignees .multiselect input')
.type(users[1].username) .type(users[1].username)
@ -334,7 +297,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.get('.remove-assignee') .get('a.remove-assignee')
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')
@ -354,7 +317,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Add Labels') .contains('Add labels')
.should('be.visible') .should('be.visible')
.click() .click()
cy.get('.task-view .details.labels-list .multiselect input') cy.get('.task-view .details.labels-list .multiselect input')
@ -377,35 +340,24 @@ describe('Task', () => {
list_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title) cy.get('.task-view .action-buttons .button')
}) .contains('Add labels')
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)
.click() .click()
cy.get('.task-view .details.labels-list .multiselect input')
addLabelToTaskAndVerify(labels[0].title) .type(labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .search-results')
cy.get('.modal-content .close') .children()
.first()
.click() .click()
cy.get('.bucket .task') cy.get('.global-notification', { timeout: 4000 })
.should('contain.text', labels[0].title) .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', () => { it('Can remove a label from a task', () => {
@ -421,13 +373,13 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) 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('be.visible')
.should('contain', labels[0].title) .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() .children()
.first() .first()
.get('[data-cy="taskDetail.removeLabel"]') .get('a.delete')
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')
@ -450,10 +402,10 @@ describe('Task', () => {
.contains('Due Date') .contains('Due Date')
.get('.date-input .datepicker .show') .get('.date-input .datepicker .show')
.click() .click()
cy.get('.datepicker .datepicker-popup button') cy.get('.datepicker .datepicker-popup a')
.contains('Tomorrow') .contains('Tomorrow')
.click() .click()
cy.get('[data-cy="closeDatepicker"]') cy.get('.datepicker .datepicker-popup a.button')
.contains('Confirm') .contains('Confirm')
.click() .click()
@ -464,87 +416,5 @@ describe('Task', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .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.wait(5000) // It can take waaaayy too long to log the user in
cy.url().should('include', '/') 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', () => { 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('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.url().should('include', '/') cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00 cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`) cy.get('h2').should('contain', `Hi ${fixture.username}!`)
}) })
it.only('Should fail', () => { it('Should fail', () => {
const fixture = { const fixture = {
username: 'test', username: 'test',
password: '123456', password: '123456',
@ -42,7 +43,8 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() 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', () => { it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
cy.visit('/user/settings/avatar') cy.visit('/user/settings/avatar')
cy.get('input[name=avatarProvider][value=upload]') cy.get('input[name=avatarProvider][value=upload]')
.click() .click()
cy.get('input[type=file]', {timeout: 1000}) cy.get('input[type=file]', { timeout: 1000 })
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose .attachFile('image.jpg')
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south') cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
.trigger('mousedown', {which: 1}) .trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100}) .trigger('mousemove', {clientY: 100})
.trigger('mouseup') .trigger('mouseup')
cy.get('[data-cy="uploadAvatar"]') cy.get('a.button.is-primary')
.contains('Upload Avatar') .contains('Upload Avatar')
.click() .click()
cy.wait('@uploadAvatar') cy.wait(3000) // Wait for the request to finish
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
}) })
@ -35,7 +33,7 @@ describe('User Settings', () => {
cy.get('.general-settings .control input.input') cy.get('.general-settings .control input.input')
.first() .first()
.type('Lorem Ipsum') .type('Lorem Ipsum')
cy.get('[data-cy="saveGeneralSettings"]') cy.get('.card.general-settings .button.is-primary')
.contains('Save') .contains('Save')
.click() .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 './commands'
import 'cypress-file-upload'
import '@4tw/cypress-drag-drop' import '@4tw/cypress-drag-drop'
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275 // 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vikunja</title> <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="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="theme-color" content="#1973ff"/> <meta name="theme-color" content="#1973ff"/>

View file

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

View file

@ -6,110 +6,79 @@ pid /var/run/nginx.pid;
events { events {
worker_connections 1024; worker_connections 1024;
} }
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
types { log_format main '$remote_addr - $remote_user [$time_local] "$request" '
application/manifest+json webmanifest; '$status $body_bytes_sent "$http_referer" '
} '"$http_user_agent" "$http_x_forwarded_for"';
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' access_log /var/log/nginx/access.log main;
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main; sendfile on;
#tcp_nopush on;
sendfile on; keepalive_timeout 65;
#tcp_nopush on;
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; map_hash_max_size 128;
gzip_proxied any; map_hash_bucket_size 128;
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; # Expires map
map_hash_bucket_size 128; 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 server {
map $sent_http_content_type $expires { listen 80;
default off; listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
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 { server_name _;
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
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 error_page 500 502 503 504 /50x.html;
location ^~ /assets/ { location = /50x.html {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable"; root /usr/share/nginx/html;
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 {
}
}
} }

View file

@ -49,7 +49,7 @@
inkscape:label="ink_ext_XXXXXX 1" inkscape:label="ink_ext_XXXXXX 1"
style="display:inline" style="display:inline"
transform="translate(-92.67749,-674.48297)"><circle 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" id="path920"
cx="242.67749" cx="242.67749"
cy="828.77881" cy="828.77881"

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -5,110 +5,152 @@
"scripts": { "scripts": {
"serve": "vite", "serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js", "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": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true 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/", "build:dev": "vite build -m development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts", "lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"lint:markup": "vue-tsc --noEmit",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"test:unit": "vitest --run", "test:unit": "jest",
"test:unit-watch": "vitest watch",
"test:frontend": "cypress run", "test:frontend": "cypress run",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "npx browserslist@latest --update-db" "browserslist:update": "npx browserslist@latest --update-db"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "6.2.0", "@github/hotkey": "1.6.0",
"@fortawesome/free-regular-svg-icons": "6.2.0", "@kyvg/vue3-notification": "2.3.4",
"@fortawesome/free-solid-svg-icons": "6.2.0", "@sentry/tracing": "6.15.0",
"@fortawesome/vue-fontawesome": "3.0.1", "@sentry/vue": "6.15.0",
"@github/hotkey": "2.0.1", "@vue/compat": "3.2.22",
"@kyvg/vue3-notification": "2.4.1", "bulma": "0.9.3",
"@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",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"codemirror": "5.65.9", "codemirror": "5.63.3",
"date-fns": "2.29.3", "copy-to-clipboard": "3.3.1",
"dompurify": "2.4.0", "date-fns": "2.25.0",
"easymde": "2.18.0", "dompurify": "2.3.3",
"flatpickr": "4.6.13", "easymde": "2.15.0",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21", "flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20", "highlight.js": "11.3.1",
"highlight.js": "11.6.0",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"marked": "4.1.1", "marked": "4.0.3",
"minimist": "1.2.7",
"pinia": "2.0.23",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"snake-case": "3.0.4", "snake-case": "3.0.4",
"sortablejs": "1.15.0", "ufo": "0.7.9",
"ufo": "0.8.5", "vue": "3.2.22",
"vue": "3.2.40", "vue-advanced-cropper": "2.7.0",
"vue-advanced-cropper": "2.8.6",
"vue-drag-resize": "2.0.3", "vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.8", "vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.2", "vue-i18n": "9.2.0-beta.18",
"vue-router": "4.1.5", "vue-router": "4.0.12",
"workbox-precaching": "6.5.4", "vuedraggable": "4.1.0",
"zhyswan-vuedraggable": "4.1.3" "vuex": "4.0.2",
"workbox-precaching": "6.4.1"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1", "@4tw/cypress-drag-drop": "2.0.0",
"@cypress/vite-dev-server": "3.3.1", "@fortawesome/fontawesome-svg-core": "1.2.36",
"@cypress/vue": "4.2.0", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@faker-js/faker": "7.5.0", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@rushstack/eslint-patch": "1.2.0", "@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/dompurify": "2.3.4", "@types/flexsearch": "0.7.2",
"@types/flexsearch": "0.7.3", "@types/jest": "27.0.2",
"@types/lodash.debounce": "4.0.7", "@typescript-eslint/eslint-plugin": "5.4.0",
"@types/marked": "4.0.7", "@typescript-eslint/parser": "5.4.0",
"@types/node": "16.11.65", "@vitejs/plugin-legacy": "1.6.2",
"@typescript-eslint/eslint-plugin": "5.40.0", "@vitejs/plugin-vue": "1.9.4",
"@typescript-eslint/parser": "5.40.0", "@vue/eslint-config-typescript": "9.1.0",
"@vitejs/plugin-legacy": "2.2.0", "autoprefixer": "10.4.0",
"@vitejs/plugin-vue": "3.1.2", "axios": "0.24.0",
"@vue/eslint-config-typescript": "11.0.2", "browserslist": "4.18.1",
"@vue/test-utils": "2.1.0", "cypress": "9.0.0",
"@vue/tsconfig": "0.1.3", "cypress-file-upload": "5.0.8",
"autoprefixer": "10.4.12", "esbuild": "0.13.14",
"browserslist": "4.21.4", "eslint": "8.2.0",
"caniuse-lite": "1.0.30001418", "eslint-plugin-vue": "8.0.3",
"cypress": "10.10.0", "express": "4.17.1",
"esbuild": "0.15.10", "faker": "5.5.3",
"eslint": "8.25.0", "jest": "27.3.1",
"eslint-plugin-vue": "9.6.0", "netlify-cli": "6.14.25",
"express": "4.18.2", "postcss": "8.3.11",
"happy-dom": "7.4.0", "rollup": "2.60.0",
"netlify-cli": "12.0.7", "rollup-plugin-visualizer": "5.5.2",
"postcss": "8.4.17", "sass": "1.43.4",
"postcss-preset-env": "7.8.2", "slugify": "1.6.2",
"rollup": "3.0.0", "ts-jest": "27.0.7",
"rollup-plugin-visualizer": "5.8.2", "typescript": "4.4.4",
"sass": "1.55.0", "vite": "2.6.14",
"typescript": "4.8.4", "vite-plugin-pwa": "0.11.5",
"vite": "3.1.7", "vite-svg-loader": "3.1.0",
"vite-plugin-pwa": "0.13.1", "vue-tsc": "0.29.5",
"vite-svg-loader": "3.6.0", "wait-on": "6.0.0",
"vitest": "0.24.1", "workbox-cli": "6.4.1"
"vue-tsc": "1.0.5", },
"wait-on": "6.0.1", "eslintConfig": {
"workbox-cli": "6.5.4" "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": { "postcss": {
"plugins": { "plugins": {
"autoprefixer": {} "autoprefixer": {}
} }
}, },
"license": "AGPL-3.0-or-later", "jest": {
"packageManager": "pnpm@7.13.4" "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"], "labels": ["dependencies"],
"extends": [ "extends": [
"config:base" "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_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}" 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_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}" 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/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/'\/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_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 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_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 {exec} = require('child_process')
const axios = require('axios') const axios = require('axios')
const BOT_USER_ID = 513 const BOT_USER_ID = 513
const giteaToken = process.env.GITEA_TOKEN const giteaToken = process.env.GITEA_TOKEN
const siteId = process.env.NETLIFY_SITE_ID const siteId = process.env.NETLIFY_SITE_ID
const branchSlug = String(process.env.DRONE_SOURCE_BRANCH) const branchSlug = slugify(process.env.DRONE_SOURCE_BRANCH)
.trim()
.normalize('NFKD')
.toLowerCase()
.replace(/[.\s/]/g, '-')
.replace(/[^A-Za-z\d-]/g, '')
const prNumber = process.env.DRONE_PULL_REQUEST const prNumber = process.env.DRONE_PULL_REQUEST
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments` 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 fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app`
const promiseExec = cmd => { const promiseExec = cmd => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(cmd, (error, stdout) => { exec(cmd, (error, stdout, stderr) => {
if (error) { if (error) {
reject(error) reject(error)
return 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 app = express()
const p = path.join(__dirname, '..', 'dist-dev') const p = path.join(__dirname, '..', 'dist-dev')
const port = 4173 const port = 5000
app.use(express.static(p)) app.use(express.static(p))
// Handle urls set by the frontend // Handle urls set by the frontend

View file

@ -1,92 +1,112 @@
<template> <template>
<ready> <ready>
<template v-if="authUser"> <div :class="{'is-touch': isTouch}">
<TheNavigation/> <div :class="{'is-hidden': !online}">
<content-auth/> <template v-if="authUser">
</template> <top-navigation/>
<content-link-share v-else-if="authLinkShare"/> <content-auth/>
<no-auth-wrapper v-else> </template>
<router-view/> <content-link-share v-else-if="authLinkShare"/>
</no-auth-wrapper> <content-no-auth v-else/>
<Notification/> <notification/>
</div>
<keyboard-shortcuts v-if="keyboardShortcutsActive"/> <transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
</div>
</ready> </ready>
</template> </template>
<script lang="ts" setup> <script>
import {computed, watch, type Ref} from 'vue' import {defineComponent} from 'vue'
import {useRouter} from 'vue-router' import {mapState, mapGetters} from 'vuex'
import {useRouteQuery} from '@vueuse/router'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device' 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 {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete' import AccountDeleteService from '@/services/accountDelete'
import Ready from '@/components/misc/ready'
import {useBaseStore} from '@/stores/base' export default defineComponent({
import {useColorScheme} from '@/composables/useColorScheme' name: 'app',
import {useBodyClass} from '@/composables/useBodyClass' components: {
import {useAuthStore} from './stores/auth' 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() localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
const authStore = useAuthStore() this.$router.push({name: 'user.password-reset.reset'})
const router = useRouter() },
setupEmailVerificationRedirect() {
if (typeof this.$route.query.userEmailConfirm === 'undefined') {
return
}
useBodyClass('is-touch', isTouchDevice()) localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive) this.$router.push({name: 'user.login'})
},
async setupAccountDeletionVerification() {
if (typeof this.$route.query.accountDeletionConfirm === 'undefined') {
return
}
const authUser = computed(() => authStore.authUser) const accountDeletionService = new AccountDeleteService()
const authLinkShare = computed(() => authStore.authLinkShare) await accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
this.$message.success({message: this.$t('user.deletion.confirmSuccess')})
const {t} = useI18n({useScope: 'global'}) this.$store.dispatch('auth/refreshUserInfo')
},
// 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()
</script> </script>
<style lang="scss"> <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"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component' import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component' import LogoFullPride from '@/assets/logo-full-pride.svg?component'
const now = useNow() const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull)
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
</script> </script>
<template> <template>
<Logo alt="Vikunja" class="logo" /> <Logo alt="Vikunja" />
</template> </template>
<style lang="scss" scoped>
.logo {
color: var(--logo-text-color);
}
</style>

View file

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

View file

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

View file

@ -1,124 +1,135 @@
<template> <template>
<div class="content-auth"> <div>
<BaseButton <a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
v-if="menuActive" <icon icon="times" />
@click="baseStore.setMenuActive(false)" </a>
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
</BaseButton>
<div <div
:class="{'has-background': background || blurHash}" :class="{'has-background': background}"
:style="{'background-image': blurHash && `url(${blurHash})`}" :style="{'background-image': background && `url(${background})`}"
class="app-container" class="app-container"
> >
<navigation/>
<div <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="[ :class="[
{ 'is-menu-enabled': menuActive }, { 'is-menu-enabled': menuActive },
$route.name, $route.name,
]" ]"
class="app-content" class="app-content"
> >
<BaseButton <a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
/>
<quick-actions/> <quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }"> <router-view/>
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/> <router-view name="popup" v-slot="{ Component }">
</keep-alive> <transition name="modal">
<component :is="Component" />
</transition>
</router-view> </router-view>
<modal <a
v-if="currentModal" class="keyboard-shortcuts-button"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal"/>
</modal>
<BaseButton
class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()" @click="showKeyboardShortcuts()"
v-shortcut="'?'" v-shortcut="'?'"
> >
<icon icon="keyboard"/> <icon icon="keyboard"/>
</BaseButton> </a>
</main> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script>
import {watch, computed} from 'vue' import {mapState} from 'vuex'
import {useRoute} from 'vue-router' import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import Navigation from '@/components/home/navigation.vue' import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue' import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base' export default {
import {useLabelStore} from '@/stores/labels' 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' // Check if the token is still valid if the window gets focus again to maybe renew it
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus' window.addEventListener('focus', () => {
const {routeWithModal, currentModal, closeModal} = useRouteWithModal() if (!this.authenticated) {
return
}
const baseStore = useBaseStore() const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive)
function showKeyboardShortcuts() { // If the token expiry is negative, it is already expired and we have no choice but to redirect
baseStore.setKeyboardShortcutsActive(true) // 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -133,7 +144,7 @@ labelStore.loadAllLabels()
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-size: 2rem; font-size: 2rem;
color: var(--grey-400); color: $grey-400;
line-height: 1; line-height: 1;
transition: all $transition; transition: all $transition;
@ -144,52 +155,45 @@ labelStore.loadAllLabels()
&:hover, &:hover,
&:focus { &:focus {
height: 1rem; height: 1rem;
color: var(--grey-600); color: $grey-600;
} }
} }
.app-container { .app-container {
min-height: calc(100vh - 65px); min-height: calc(100vh - 65px);
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
padding-top: $navbar-height; 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 { .card {
z-index: 10; background: $white;
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);
}
}
} }
.mobile-overlay { .mobile-overlay {
@ -199,9 +203,7 @@ labelStore.loadAllLabels()
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 100vh; background: rgba(250, 250, 250, 0.8);
width: 100vw;
background: hsla(var(--grey-100-hsl), 0.8);
z-index: 5; z-index: 5;
opacity: 0; opacity: 0;
transition: all $transition; transition: all $transition;
@ -218,23 +220,11 @@ labelStore.loadAllLabels()
right: 1rem; right: 1rem;
z-index: 4500; // The modal has a z-index of 4000 z-index: 4500; // The modal has a z-index of 4000
color: var(--grey-500); color: $grey-500;
transition: color $transition; transition: color $transition;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
display: none; display: none;
} }
} }
.content-auth {
position: relative;
z-index: 1;
}
.is-touch .content-auth,
.content-auth.z-unset {
z-index: unset;
}
@include modal-transition();
</style> </style>

View file

@ -1,52 +1,55 @@
<template> <template>
<div <div
:class="[background ? 'has-background' : '', $route.name as string +'-view']" :class="[background ? 'has-background' : '', $route.name+'-view']"
:style="{'background-image': `url(${background})`}" :style="{'background-image': `url(${background})`}"
class="link-share-container" class="link-share-container"
> >
<div class="container has-text-centered link-share-view"> <div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1"> <div class="column is-10 is-offset-1">
<Logo class="logo" v-if="logoVisible"/> <Logo class="logo" />
<h1 <h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }" :style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title"> class="title">
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }} {{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1> </h1>
<div class="box has-text-left view"> <div class="box has-text-left view">
<router-view/> <router-view/>
<PoweredByLink/> <PoweredByLink />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script>
import {computed} from 'vue' import {mapState} from 'vuex'
import {useBaseStore} from '@/stores/base'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue' import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore() export default {
const currentList = computed(() => baseStore.currentList) name: 'contentLinkShare',
const background = computed(() => baseStore.background) components: {
const logoVisible = computed(() => baseStore.logoVisible) Logo,
PoweredByLink,
},
computed: mapState([
'currentList',
'background',
]),
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.link-share-container.has-background .view { .link-share-container.has-background .view {
background-color: transparent; background-color: transparent;
border: none; border: none;
} }
.logo { .logo {
max-width: 300px; max-width: 300px;
width: 90%; width: 90%;
margin: 2rem 0 1.5rem; margin: 2rem 0 1.5rem;
height: 100px;
} }
.column { .column {
@ -54,11 +57,11 @@ const logoVisible = computed(() => baseStore.logoVisible)
} }
.title { .title {
text-shadow: 0 0 1rem var(--white); text-shadow: 0 0 1rem $white;
} }
// FIXME: this should be defined somewhere deep // FIXME: this should be defined somewhere deep
.link-share-view .card { .link-share-view .card {
background-color: var(--white); background-color: $white;
} }
</style> </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> <template>
<aside :class="{'is-active': menuActive}" class="namespace-container"> <div :class="{'is-active': menuActive}" class="namespace-container">
<nav class="menu top-menu"> <div class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo"> <router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/> <Logo width="164" height="48" />
</router-link> </router-link>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'"> <router-link :to="{ name: 'home'}">
<span class="icon"> <span class="icon">
<icon icon="calendar"/> <icon icon="calendar"/>
</span> </span>
@ -14,7 +14,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'"> <router-link :to="{ name: 'tasks.range'}">
<span class="icon"> <span class="icon">
<icon :icon="['far', 'calendar-alt']"/> <icon :icon="['far', 'calendar-alt']"/>
</span> </span>
@ -22,7 +22,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'"> <router-link :to="{ name: 'namespaces.index'}">
<span class="icon"> <span class="icon">
<icon icon="layer-group"/> <icon icon="layer-group"/>
</span> </span>
@ -30,7 +30,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'"> <router-link :to="{ name: 'labels.index'}">
<span class="icon"> <span class="icon">
<icon icon="tags"/> <icon icon="tags"/>
</span> </span>
@ -38,7 +38,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'"> <router-link :to="{ name: 'teams.index'}">
<span class="icon"> <span class="icon">
<icon icon="users"/> <icon icon="users"/>
</span> </span>
@ -46,54 +46,56 @@
</router-link> </router-link>
</li> </li>
</ul> </ul>
</nav> </div>
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}"> <aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id"> <template v-for="(n, nk) in namespaces" :key="n.id" >
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<BaseButton <span
@click="toggleLists(n.id)" @click="toggleLists(n.id)"
class="menu-label" class="menu-label"
v-tooltip="namespaceTitles[nk]" v-tooltip="namespaceTitles[nk]">
> <span class="name">
<ColorBubble <span
v-if="n.hexColor !== ''" :style="{ backgroundColor: n.hexColor }"
:color="n.hexColor" class="color-bubble"
class="mr-1" v-if="n.hexColor !== ''">
/> </span>
<span class="name">{{ namespaceTitles[nk] }}</span> {{ namespaceTitles[nk] }}
<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] }})
</span> </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"/> <namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div> </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 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 triggered by the change needs to have access to the current namespace
--> -->
<draggable <draggable
v-if="listsVisible[n.id] ?? true"
v-bind="dragOptions" v-bind="dragOptions"
:modelValue="activeLists[nk]" :modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)" @update:modelValue="(lists) => updateActiveLists(n, lists)"
group="namespace-lists" :group="`namespace-${n.id}-lists`"
@start="() => drag = true" @start="() => drag = true"
@end="saveListPosition" @end="e => saveListPosition(e, nk)"
handle=".handle" handle=".handle"
:disabled="n.id < 0 || undefined" :disabled="n.id < 0 || null"
tag="ul" tag="transition-group"
item-key="id" item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{ :component-data="{
type: 'transition-group', type: 'transition',
tag: 'ul',
name: !drag ? 'flip-list' : null, name: !drag ? 'flip-list' : null,
class: [ class: [
'menu-list can-be-hidden', 'menu-list can-be-hidden',
@ -103,184 +105,187 @@
> >
<template #item="{element: l}"> <template #item="{element: l}">
<li <li
class="list-menu loader-container is-loading-small" class="loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}" :class="{'is-loading': listUpdating[l.id]}"
> >
<BaseButton <router-link
:to="{ name: 'list.index', params: { listId: l.id} }" :to="{ name: 'list.index', params: { listId: l.id} }"
class="list-menu-link" v-slot="{ href, navigate, isActive }"
:class="{'router-link-exact-active': currentList.id === l.id}" custom
> >
<span class="icon handle"> <a
<icon icon="grip-lines"/> @click="navigate"
</span> :href="href"
<ColorBubble class="list-menu-link"
v-if="l.hexColor !== ''" :class="{'router-link-exact-active': isActive || currentList?.id === l.id}"
:color="l.hexColor" >
class="mr-1" <span class="icon handle">
/> <icon icon="grip-lines"/>
<span class="list-menu-title">{{ getListTitle(l) }}</span> </span>
</BaseButton> <span
<BaseButton :style="{ backgroundColor: l.hexColor }"
class="favorite" class="color-bubble"
:class="{'is-favorite': l.isFavorite}" v-if="l.hexColor !== ''">
@click="listStore.toggleListFavorite(l)" </span>
> <span class="list-menu-title">
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/> {{ getListTitle(l) }}
</BaseButton> </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"/> <list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span> <span class="list-setting-spacer" v-else></span>
</li> </li>
</template> </template>
</draggable> </draggable>
</div>
</template> </template>
</nav> </aside>
<PoweredByLink/> <PoweredByLink />
</aside> </div>
</template> </template>
<script setup lang="ts"> <script>
import {ref, computed, onMounted, onBeforeMount} from 'vue' import {mapState} from 'vuex'
import draggable from 'zhyswan-vuedraggable' import draggable from 'vuedraggable'
import type {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue' import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue' import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.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 {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(() => { export default {
return namespaceStore.namespaces.filter(n => !n.isArchived) name: 'navigation',
})
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
return lists?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
})
const namespaceTitles = computed(() => { components: {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace)) ListSettingsDropdown,
}) NamespaceSettingsDropdown,
draggable,
Logo,
PoweredByLink,
},
const namespaceListsCount = computed(() => { data() {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0) return {
}) listsVisible: {},
drag: false,
dragOptions: {
useEventListener('resize', resize) animation: 100,
onMounted(() => resize()) ghostClass: 'ghost',
},
const listStore = useListStore() listUpdating: {},
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
} }
}) },
}) 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[]) { const newNamespace = {
// This is a bit hacky: since we do have to filter out the archived items from the list ...namespace,
// for vue draggable updating it is not as simple as replacing it. lists,
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order }
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier.
const lists = [
...activeLists,
...namespace.lists.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({ this.$store.commit('namespaces/setNamespaceById', newNamespace)
...namespace, },
lists,
})
}
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) { const position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
if (!e.newIndex && e.newIndex !== 0) return
const namespaceId = parseInt(e.to.dataset.namespaceId as string) try {
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string) // create a copy of the list in order to not violate vuex mutations
await this.$store.dispatch('lists/updateList', {
const listsActive = activeLists.value[newNamespaceIndex] ...list,
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive position,
// 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. } finally {
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex this.listUpdating[list.id] = false
}
const list = listsActive[newIndex] },
const listBefore = listsActive[newIndex - 1] ?? null },
const listAfter = listsActive[newIndex + 1] ?? null
listUpdating.value[list.id] = true
const position = calculateItemPosition(
listBefore !== null ? listBefore.position : null,
listAfter !== null ? listAfter.position : null,
)
try {
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
...list,
position,
namespaceId,
})
} finally {
listUpdating.value[list.id] = false
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
$navbar-padding: 2rem; $navbar-padding: 2rem;
$vikunja-nav-background: var(--site-background); $vikunja-nav-background: $light-background;
$vikunja-nav-color: var(--grey-700); $vikunja-nav-color: $grey-700;
$vikunja-nav-selected-width: 0.4rem; $vikunja-nav-selected-width: 0.4rem;
.namespace-container { .namespace-container {
z-index: 6;
background: $vikunja-nav-background; background: $vikunja-nav-background;
color: $vikunja-nav-color; color: $vikunja-nav-color;
padding: 0 0 1rem; padding: 0 0 1rem;
@ -293,10 +298,10 @@ $vikunja-nav-selected-width: 0.4rem;
overflow-x: auto; overflow-x: auto;
width: $navbar-width; width: $navbar-width;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
top: 0; top: 0;
width: 70vw; width: 70vw;
z-index: 20;
} }
&.is-active { &.is-active {
@ -320,7 +325,7 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.menu-label, .menu-label,
.menu-list .list-menu-link, .menu-list span.list-menu-link,
.menu-list a { .menu-list a {
display: flex; display: flex;
align-items: center; align-items: center;
@ -338,28 +343,30 @@ $vikunja-nav-selected-width: 0.4rem;
flex: 0 0 12px; flex: 0 0 12px;
} }
} .favorite {
.favorite { margin-left: .25rem;
margin-left: .25rem; transition: opacity $transition, color $transition;
transition: opacity $transition, color $transition; opacity: 0;
opacity: 0;
&:hover, &:hover {
&.is-favorite { color: $orange;
color: var(--warning); }
&.is-favorite {
opacity: 1;
color: $orange;
}
} }
}
.favorite.is-favorite, &:hover .favorite {
.list-menu:hover .favorite { opacity: 1;
opacity: 1; }
} }
.menu-label { .menu-label {
.color-bubble { .color-bubble {
width: 14px; width: 14px !important;
height: 14px; height: 14px !important;
flex-basis: auto;
} }
.is-archived { .is-archived {
@ -371,8 +378,6 @@ $vikunja-nav-selected-width: 0.4rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
color: $vikunja-nav-color;
padding: 0 .25rem;
.menu-label { .menu-label {
margin-bottom: 0; margin-bottom: 0;
@ -382,13 +387,12 @@ $vikunja-nav-selected-width: 0.4rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-right: auto;
} }
}
.count { a:not(.dropdown-item) {
color: var(--grey-500); color: $vikunja-nav-color;
margin-right: .5rem; padding: 0 .25rem;
}
} }
:deep(.dropdown-trigger) { :deep(.dropdown-trigger) {
@ -420,7 +424,7 @@ $vikunja-nav-selected-width: 0.4rem;
.menu-label, .menu-label,
.nsettings, .nsettings,
.menu-list .list-menu-link, .menu-list span.list-menu-link,
.menu-list a { .menu-list a {
color: $vikunja-nav-color; color: $vikunja-nav-color;
} }
@ -432,7 +436,7 @@ $vikunja-nav-selected-width: 0.4rem;
align-items: center; align-items: center;
&:hover { &:hover {
background: var(--white); background: $white;
} }
:deep(.dropdown-trigger) { :deep(.dropdown-trigger) {
@ -452,7 +456,7 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.ghost { .ghost {
background: var(--grey-200); background: $grey-200;
* { * {
opacity: 0; opacity: 0;
@ -463,7 +467,7 @@ $vikunja-nav-selected-width: 0.4rem;
background: transparent; 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); padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease; transition: all 0.2s ease;
@ -478,7 +482,7 @@ $vikunja-nav-selected-width: 0.4rem;
height: 1rem; height: 1rem;
vertical-align: middle; vertical-align: middle;
padding-right: 0.5rem; padding-right: 0.5rem;
&.handle { &.handle {
opacity: 0; opacity: 0;
transition: opacity $transition; transition: opacity $transition;
@ -486,22 +490,22 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: grab; cursor: grab;
} }
} }
&:hover .icon.handle { &:hover .icon.handle {
opacity: 1; opacity: 1;
} }
&.router-link-exact-active { &.router-link-exact-active {
color: var(--primary); color: $primary;
border-left: $vikunja-nav-selected-width solid var(--primary); border-left: $vikunja-nav-selected-width solid $primary;
.icon { .icon {
color: var(--primary); color: $primary;
} }
} }
&:hover { &: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 { .logo {
display: block; display: block;
padding-left: 1rem; padding-left: 2rem;
margin-right: 1rem; margin-right: 1rem;
margin-bottom: 1rem;
@media screen and (min-width: $tablet) { @media screen and (min-width: $tablet) {
display: none; display: none;
@ -523,7 +526,7 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.icon { .icon {
color: var(--grey-400) !important; color: $grey-400 !important;
} }
} }
@ -536,10 +539,10 @@ $vikunja-nav-selected-width: 0.4rem;
font-family: $vikunja-font; font-family: $vikunja-font;
} }
.list-menu-link, li > a { span.list-menu-link, li > a {
padding-left: 2rem; padding-left: 2rem;
display: inline-block; display: inline-block;
.icon { .icon {
padding-bottom: .25rem; padding-bottom: .25rem;
} }
@ -549,11 +552,7 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.list-setting-spacer { .list-setting-spacer {
width: 2.5rem; width: 32px;
flex-shrink: 0; flex-shrink: 0;
} }
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
</style> </style>

View file

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

View file

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

View file

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

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