Compare commits

..

1 Commits

Author SHA1 Message Date
renovate a4849ac3dd fix(deps): update dependency flexsearch to v0.7.31 2023-01-06 10:04:20 +00:00
386 changed files with 23977 additions and 33609 deletions

View File

@ -15,7 +15,6 @@ trigger:
services:
- name: api
image: vikunja/api:unstable
pull: always
environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
@ -42,12 +41,11 @@ steps:
# - .cache
- name: dependencies
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
PUPPETEER_SKIP_DOWNLOAD: true
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
@ -55,7 +53,7 @@ steps:
# - restore-cache
- name: lint
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -66,7 +64,7 @@ steps:
- dependencies
- name: build-prod
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -77,7 +75,7 @@ steps:
- dependencies
- name: test-unit
image: node:20.9-alpine
image: node:18-alpine
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -87,7 +85,7 @@ steps:
- name: typecheck
failure: ignore
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -137,9 +135,8 @@ steps:
# - dependencies
- name: deploy-preview
image: williamjackson/netlify-cli
image: node:18-alpine
pull: always
user: root # The rest runs as root and thus the permissions wouldn't work
environment:
NETLIFY_AUTH_TOKEN:
from_secret: netlify_auth_token
@ -152,10 +149,8 @@ steps:
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
- node ./scripts/deploy-preview-netlify.mjs
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
- build-prod
when:
@ -202,18 +197,13 @@ steps:
# - .cache
- name: build
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
SENTRY_AUTH_TOKEN:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
PUPPETEER_SKIP_DOWNLOAD: true
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && pnpm config set store-dir .cache/.pnpm
- pnpm install --fetch-timeout 100000 --frozen-lockfile
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
@ -226,7 +216,6 @@ steps:
image: kolaente/zip
pull: always
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-unstable.zip *
- cd ..
@ -285,14 +274,10 @@ steps:
# - .cache
- name: build
image: node:20.9-alpine
image: node:18-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
SENTRY_AUTH_TOKEN:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -308,7 +293,6 @@ steps:
image: kolaente/zip
pull: always
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
- cd ..
@ -360,7 +344,8 @@ type: docker
name: docker-release
depends_on:
- build
- release-latest
- release-version
trigger:
ref:
@ -388,7 +373,8 @@ steps:
repo: vikunja/frontend
tags: unstable
build_args:
- USE_RELEASE=false
- USE_RELEASE=true
- RELEASE_VERSION=unstable
platforms:
- linux/386
- linux/amd64
@ -400,17 +386,6 @@ steps:
ref:
- refs/heads/main
- name: generate-tags
image: thegeeklab/docker-autotag
environment:
DOCKER_AUTOTAG_VERSION: ${DRONE_TAG}
DOCKER_AUTOTAG_EXTRA_TAGS: latest
DOCKER_AUTOTAG_OUTPUT_FILE: .tags
depends_on: [ fetch-tags ]
when:
ref:
- "refs/tags/**"
- name: docker-release
image: thegeeklab/drone-docker-buildx
privileged: true
@ -421,15 +396,17 @@ steps:
password:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
build_args:
- USE_RELEASE=false
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
platforms:
- linux/386
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64/v8
depends_on: [ generate-tags ]
depends_on: [ fetch-tags ]
when:
ref:
- "refs/tags/**"
@ -475,25 +452,24 @@ name: update-translations
trigger:
branch:
include:
- main
- main
event:
include:
- cron
- cron
cron:
- update_translations
steps:
- name: download
pull: always
image: git.lcomrade.su/root/drone-crowdin-v2
image: jonasfranz/crowdin
settings:
crowdin_key:
download: true
export_dir: src/i18n/lang/
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
project_id: 462614
target: download
download_to: src/i18n/lang/
download_export_approved_only: true
- name: move-files
pull: always
@ -516,22 +492,23 @@ steps:
commit_message: "[skip ci] Updated translations via Crowdin"
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
ssh_key:
from_secret: git_push_ssh_key
from_secret: translation_git_push_ssh_key
- name: upload
pull: always
image: git.lcomrade.su/root/drone-crowdin-v2
image: jonasfranz/crowdin
depends_on:
- clone
settings:
crowdin_key:
from_secret: crowdin_key
project_id: 462614
target: upload
upload_files:
src/i18n/lang/en.json: en.json
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
---
kind: signature
hmac: dab902060979f246df77641c995c843ea39f86dba2de9003da7e593ce6f6f08a
hmac: dd67ef81f5bd85633fa72c9335fbceebbba5baebce5ebc310452459fd3dae2a8
...

View File

@ -1,13 +0,0 @@
# (1) Duplicate this file and remove the '.example' suffix.
# Naming this file '.env.local' is a Vite convention to prevent accidentally
# submitting to git.
# For more info see: https://vitejs.dev/guide/env-and-mode.html#env-files
# (2) Comment in and adjust the values as needed.
# VITE_IS_ONLINE=true
# VITE_WORKBOX_DEBUG=false
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
# SENTRY_ORG=vikunja
# SENTRY_PROJECT=frontend-oss
# VIKUNJA_FRONTEND_BASE=/custom-subpath

3
.github/FUNDING.yml vendored
View File

@ -1,3 +1,2 @@
github: kolaente
open_collective: vikunja
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]
custom: https://www.buymeacoffee.com/kolaente

View File

@ -1,23 +0,0 @@
name: 'Repo Lockdown'
on:
pull_request_target:
types: opened
permissions:
issues: write
pull-requests: write
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/repo-lockdown@v3
with:
pr-comment: 'Hi! Thank you for your contribution.
This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/frontend/pulls).
Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests).
Thank you for your understanding.'

14
.npmrc
View File

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

2
.nvmrc
View File

@ -1 +1 @@
20.9.0
v18

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,49 @@
# syntax=docker/dockerfile:1
# ┬─┐┬ ┐o┬ ┬─┐
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20.9-alpine AS builder
# Stage 1: Build application
FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=unstable
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true
ADD . ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches/
RUN \
if [ $USE_RELEASE = true ]; then \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm install && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
RUN if [ "$USE_RELEASE" != true ]; then \
# https://pnpm.io/installation#using-corepack
corepack enable && \
pnpm install; \
fi
# Stage 2: copy
FROM nginx:alpine
COPY . ./
COPY nginx.conf /etc/nginx/nginx.conf
COPY scripts/run.sh /run.sh
RUN if [ "$USE_RELEASE" != true ]; then \
apk add --no-cache --virtual .build-deps git jq && \
git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \
xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \
tee src/version.json && \
apk del .build-deps; \
fi
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html
RUN if [ "$USE_RELEASE" = true ]; then \
wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \
unzip frontend-release.zip -d dist/; \
else \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm run build; \
fi
# Unprivileged user
ENV PUID 1000
ENV PGID 1000
# ┌┐┐┌─┐o┌┐┐┐ │
# ││││ ┬││││┌┼┘
# ┘└┘┘─┘┘┘└┘┘ └
FROM nginx:stable-alpine AS runner
WORKDIR /usr/share/nginx/html
LABEL maintainer="maintainers@vikunja.io"
ENV VIKUNJA_HTTP_PORT 80
ENV VIKUNJA_HTTP2_PORT 81
ENV VIKUNJA_LOG_FORMAT main
ENV VIKUNJA_API_URL /api/v1
ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
ENV VIKUNJA_ALLOW_ICON_CHANGES true
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/templates/. /etc/nginx/templates/
# copy compiled files from stage 1
COPY --from=builder /build/dist ./
# manage permissions
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
chmod -R 0644 /etc/nginx/nginx.conf && \
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
CMD "/run.sh"

View File

@ -4,7 +4,7 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.21.0-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.20.2-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.
@ -18,14 +18,6 @@ If you find any security-related issues you don't want to disclose publicly, ple
## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
In order to build it from sources run the command below. (Docker >= v19.03)
```shell
export DOCKER_BUILDKIT=1
docker build -t vikunja/frontend .
```
Refer to Refer [to multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for the different platform.
## Project setup
@ -50,3 +42,7 @@ pnpm run build
```shell
pnpm run lint
```
## Sponsors
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)

0
crowdin.cli Normal file
View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
describe('Editor', () => {
createFakeUserAndLogin()
beforeEach(() => {
NamespaceFactory.create(1)
ListFactory.create(1)
TaskFactory.truncate()
UserListFactory.truncate()
})
it('Has a preview with checkable checkboxes', () => {
const tasks = TaskFactory.create(1, {
description: `# Test Heading
* Bullet 1
* Bullet 2
* [ ] Checklist
* [x] Checklist checked
`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('input[type=checkbox][data-checkbox-num=0]')
.click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
.contains('Saved!')
.should('exist')
cy.get('.preview.content')
.should('contain', 'Test Heading')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,17 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {UserProjectFactory} from '../../factories/users_project'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
@ -24,7 +24,7 @@ function addLabelToTaskAndVerify(labelTitle: string) {
.first()
.click()
cy.get('.global-notification', {timeout: 4000})
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
@ -47,21 +47,23 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
createFakeUserAndLogin()
let projects
let namespaces
let lists
let buckets
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
list_id: lists[0].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
UserListFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -72,11 +74,11 @@ describe('Task', () => {
.should('contain', 'New Task')
})
it('Inserts new tasks at the top of the project', () => {
it('Inserts new tasks at the top of the list', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.project-is-empty-notice')
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
@ -93,8 +95,8 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.tasks .task .fancycheckbox')
cy.visit('/lists/1/list')
cy.get('.tasks .task .fancycheckbox label.check')
.first()
.click()
cy.get('.global-notification')
@ -104,11 +106,11 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .favorite')
.first()
.click()
cy.get('.menu-container')
cy.get('.menu.namespaces-lists')
.should('contain', 'Favorites')
})
@ -122,7 +124,7 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
index: 1,
description: 'Lorem ipsum dolor sit amet.',
description: 'Lorem ipsum dolor sit amet.'
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -131,7 +133,8 @@ describe('Task', () => {
cy.get('.task-view h1.title.task-id')
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', projects[0].title)
.should('contain', namespaces[0].title)
.should('contain', lists[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created')
@ -143,7 +146,7 @@ describe('Task', () => {
id: 1,
index: 1,
done: true,
done_at: new Date().toISOString(),
done_at: new Date().toISOString()
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -176,33 +179,33 @@ describe('Task', () => {
.should('contain', 'Mark as undone')
})
it('Shows a task identifier since the project has one', () => {
const projects = ProjectFactory.create(1, {
it('Shows a task identifier since the list has one', () => {
const lists = ListFactory.create(1, {
id: 1,
identifier: 'TEST',
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
index: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id')
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
})
it('Can edit the description', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: 'Lorem ipsum dolor sit amet.',
description: 'Lorem ipsum dolor sit amet.'
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .tiptap button.done-edit')
cy.get('.task-view .details.content.description .editor button')
.click()
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]')
.contains('Save')
@ -219,7 +222,7 @@ describe('Task', () => {
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.should('be.visible')
.type('{selectall}New Comment')
cy.get('.task-view .comments .media.comment .button:not([disabled])')
@ -227,20 +230,20 @@ describe('Task', () => {
.should('be.visible')
.click()
cy.get('.task-view .comments .media.comment .tiptap__editor')
cy.get('.task-view .comments .media.comment .editor')
.should('contain', 'New Comment')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}',
list_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -248,7 +251,7 @@ describe('Task', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -257,7 +260,8 @@ describe('Task', () => {
.click()
cy.get('.task-view h6.subtitle')
.should('contain', projects[1].title)
.should('contain', namespaces[0].title)
.should('contain', lists[1].title)
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -265,7 +269,7 @@ describe('Task', () => {
it('Can delete a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -282,17 +286,17 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.url()
.should('contain', `/projects/${tasks[0].project_id}/`)
.should('contain', `/lists/${tasks[0].list_id}/`)
})
it('Can add an assignee to a task', () => {
const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
@ -317,10 +321,10 @@ describe('Task', () => {
const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
TaskAssigneeFactory.create(1, {
@ -343,7 +347,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
LabelFactory.truncate()
const newLabelText = 'some new label'
@ -371,7 +375,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
@ -380,27 +384,27 @@ describe('Task', () => {
addLabelToTaskAndVerify(labels[0].title)
})
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
addLabelToTaskAndVerify(labels[0].title)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
})
@ -408,7 +412,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, {
@ -461,155 +465,7 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a reminder', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.contains('Tomorrow')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a relative reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.should('contain', '1 day before Due Date')
cy.get('.reminder-options-popup .card-content')
.contains('1 day before Due Date')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a relative reminder when the task already has a start date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
start_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.should('contain', '1 day before Start Date')
cy.get('.reminder-options-popup .card-content')
.contains('1 day before Start Date')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a custom relative reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.contains('Custom')
.click()
cy.get('.reminder-options-popup .card-content .reminder-period input')
.first()
.type('10')
cy.get('.reminder-options-popup .card-content .reminder-period select')
.first()
.select('days')
cy.get('.reminder-options-popup .card-content button')
.contains('Confirm')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a fixed reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.contains('Date and time')
.click()
cy.get('.datepicker__quick-select-date')
.contains('Tomorrow')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
@ -647,7 +503,7 @@ describe('Task', () => {
.select('50%')
cy.get('.global-notification')
.should('contain', 'Success')
cy.wait(200)
cy.get('.task-view .columns.details .column')
@ -656,7 +512,7 @@ describe('Task', () => {
.should('be.visible')
.should('have.value', '0.5')
})
it('Can add an attachment to a task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
@ -671,13 +527,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -691,119 +547,35 @@ describe('Task', () => {
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
it('Can check items off a checklist', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>First Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Second Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Third Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Fourth Item</p></div>
</li>
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Fifth Item</p></div>
</li>
</ul>`,
This is a checklist:
* [ ] one item
* [ ] another item
* [ ] third item
* [ ] fourth item
* [x] and this one is already done
`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .checklist-summary')
.should('contain.text', '1 of 5 tasks')
cy.get('.tiptap__editor ul > li input[type=checkbox]')
cy.get('.editor .content ul > li input[type=checkbox]')
.eq(2)
.click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
.contains('Saved!')
.should('exist')
cy.get('.tiptap__editor ul > li input[type=checkbox]')
cy.get('.editor .content ul > li input[type=checkbox]')
.eq(2)
.should('be.checked')
cy.get('.tiptap__editor input[type=checkbox]')
cy.get('.editor .content input[type=checkbox]')
.should('have.length', 5)
cy.get('.task-view .checklist-summary')
.should('contain.text', '2 of 5 tasks')
})
it('Should use the editor to render description', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
<h1>Lorem Ipsum</h1>
<p>Dolor sit amet</p>
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>First Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Second Item</p></div>
</li>
</ul>`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.tiptap__editor ul > li input[type=checkbox]')
.should('exist')
cy.get('.tiptap__editor h1')
.contains('Lorem Ipsum')
.should('exist')
cy.get('.tiptap__editor p')
.contains('Dolor sit amet')
.should('exist')
})
it.only('Should render an image from attachment', async () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
description: '',
})
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
const formData = new FormData()
formData.append('files', new Blob([file]), 'image.jpg')
cy.request({
method: 'PUT',
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
'Content-Type': 'multipart/form-data',
},
body: formData,
})
.then(({body}) => {
const dec = new TextDecoder('utf-8')
const {success} = JSON.parse(dec.decode(body))
TaskFactory.create(1, {
id: 1,
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.tiptap__editor img')
.should('be.visible')
.and(($img) => {
// "naturalWidth" and "naturalHeight" are set when the image loads
expect($img[0].naturalWidth).to.be.greaterThan(0)
})
})
})
})
})
})

View File

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

View File

@ -1,41 +1,36 @@
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login')
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.wait('@login')
cy.wait(5000) // It can take waaaayy too long to log the user in
cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.')
}
const credentials = {
username: 'test',
password: '1234',
}
function login() {
cy.get('input[id=username]').type(credentials.username)
cy.get('input[id=password]').type(credentials.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
}
const username = 'test'
context('Login', () => {
beforeEach(() => {
UserFactory.create(1, {username: credentials.username})
UserFactory.create(1, {username})
})
it('Should log in with the right credentials', () => {
const fixture = {
username: 'test',
password: '1234',
}
cy.visit('/login')
login()
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it('Should fail with a bad password', () => {
@ -60,15 +55,4 @@ context('Login', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
it('Should redirect to the previous route after logging in', () => {
const projects = ProjectFactory.create(1)
cy.visit(`/projects/${projects[0].id}/list`)
cy.url().should('include', '/login')
login()
cy.url().should('include', `/projects/${projects[0].id}/list`)
})
})

View File

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

View File

@ -17,7 +17,7 @@ context('Registration', () => {
it('Should work without issues', () => {
const fixture = {
username: 'testuser',
password: '12345678',
password: '123456',
email: 'testuser@example.com',
}
@ -31,10 +31,10 @@ context('Registration', () => {
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it('Should fail', () => {
it.only('Should fail', () => {
const fixture = {
username: 'test',
password: '12345678',
password: '123456',
email: 'testuser@example.com',
}

View File

@ -37,7 +37,7 @@ describe('User Settings', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.navbar .username-dropdown-trigger .username')
cy.get('.navbar .user .username')
.should('contain', 'Lorem Ipsum')
})
})

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
import {Factory} from '../support/factory'
export class TaskReminderFactory extends Factory {
static table = 'task_reminders'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
reminder: now.toISOString(),
created: now.toISOString(),
relative_to: '',
relative_period: 0,
}
}
}

View File

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

View File

@ -4,7 +4,7 @@ import {seed} from './seed'
* A factory makes it easy to seed the database with data.
*/
export class Factory {
static table: string | null = null
static table = null
static factory() {
return {}

View File

@ -1,18 +0,0 @@
#!/usr/bin/env sh
set -e
echo "info: API URL is $VIKUNJA_API_URL"
echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED"
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')"
VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html
date -uIseconds | xargs echo 'info: started at'

View File

@ -1,19 +0,0 @@
#!/usr/bin/env sh
set -e
if [ ! -f "/proc/net/if_inet6" ]; then
echo "info: IPv6 is not available! Removing IPv6 listen configuration"
find /etc/nginx/conf.d -name '*.conf' -type f | \
while IFS= read -r CONFIG; do
sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp"
if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then
echo "info: Removing IPv6 lines from $CONFIG" | \
cat - "$CONFIG.diff"
echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \
cat - "$CONFIG.temp" > "$CONFIG"
else
echo "info: Skipping $CONFIG because it does not have IPv6 listen"
fi
rm -f "$CONFIG.temp" "$CONFIG.diff"
done
fi

View File

@ -1,111 +0,0 @@
# Generated by nginxconfig.io
# https://www.digitalocean.com/community/tools/nginx?domains.0.server.domain=localhost&domains.0.server.documentRoot=%2Fusr%2Fshare%2Fnginx%2Fhtml&domains.0.server.cdnSubdomain=true&domains.0.https.https=false&domains.0.php.php=false&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&domains.0.routing.fallbackPhp=false&global.performance.assetsExpiration=1d&global.performance.mediaExpiration=1d&global.performance.svgExpiration=1d&global.performance.fontsExpiration=1d&global.logging.accessLog=%2Fdev%2Fstdout&global.logging.errorLog=%2Fdev%2Fstderr%20warn&global.logging.logNotFound=true&global.nginx.user=nginx&global.nginx.pid=%2Fvar%2Frun%2Fnginx.pid&global.nginx.clientMaxBodySize=50&global.docker.dockerfile=true&global.tools.modularizedStructure=false&global.tools.symlinkVhost=false
# and then edited manually ;)
pid /tmp/nginx.pid;
worker_processes auto;
events {
multi_accept on;
worker_connections 1024;
}
http {
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
types_hash_max_size 2048;
types_hash_bucket_size 64;
# rootless
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# MIME
include mime.types;
default_type application/octet-stream;
types {
application/manifest+json webmanifest;
}
# Logging
log_format json escape=json
'{'
'"bytes_sent": "$bytes_sent",'
'"http_user_agent": "$http_user_agent",'
'"nginx_version": "$nginx_version",'
'"query_string": "$query_string",'
'"realip_remote_addr": "$realip_remote_addr",'
'"remote_addr": "$remote_addr",'
'"remote_user": "$remote_user",'
'"request_length": "$request_length",'
'"request_method": "$request_method",'
'"request_time": "$request_time",'
'"server_addr": "$server_addr",'
'"server_port": "$server_port",'
'"server_protocol": "$server_protocol",'
'"status": "$status",'
'"time_local": "$time_local",'
'"uri": "$uri"'
'}';
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
error_log /dev/stderr warn;
keepalive_timeout 65;
# compression
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;
map_hash_max_size 128;
map_hash_bucket_size 128;
map $sent_http_content_type $expires {
default off;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~images/ max;
~font/ max;
}
include /etc/nginx/conf.d/*.conf;
}

View File

@ -1,85 +0,0 @@
server {
listen ${VIKUNJA_HTTP_PORT};
listen [::]:${VIKUNJA_HTTP_PORT};
## Needed when behind HAProxy with SSL termination + HTTP/2 support
listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
server_name _;
expires $expires;
root /usr/share/nginx/html;
access_log /dev/stdout ${VIKUNJA_LOG_FORMAT};
# security headers
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "interest-cohort=()" always;
# . files
location ~ /\.(?!well-known) {
deny all;
}
# 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;
}
# Disable caching for sw
location = /sw.js {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# Disable caching for webmanifest
location = /manifest.webmanifest {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# favicon.ico
location = /favicon.ico {
log_not_found off;
access_log off;
}
# robots.txt
location = /robots.txt {
log_not_found off;
access_log off;
expires -1; # no-cache
}
location = /ready {
return 200 "";
access_log off;
expires -1; # no-cache
}
# all assets contain hash in filename, cache forever
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# all workbox scripts are compiled with hash in filename, cache forever3
location ^~ /workbox- {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# assets, media
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

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

26
env.d.ts vendored
View File

@ -3,32 +3,10 @@
/// <reference types="cypress" />
/// <reference types="@histoire/plugin-vue/components" />
declare module 'postcss-focus-within/browser' {
import focusWithinInit from 'postcss-focus-within/browser'
export default focusWithinInit
}
declare module 'css-has-pseudo/browser' {
import cssHasPseudo from 'css-has-pseudo/browser'
export default cssHasPseudo
}
interface ImportMetaEnv {
readonly VIKUNJA_API_URL?: string
readonly VIKUNJA_HTTP_PORT?: number
readonly VIKUNJA_HTTPS_PORT?: number
readonly VIKUNJA_SENTRY_ENABLED?: boolean
readonly VIKUNJA_SENTRY_DSN?: string
readonly SENTRY_AUTH_TOKEN?: string
readonly SENTRY_ORG?: string
readonly SENTRY_PROJECT?: string
readonly VITE_WORKBOX_DEBUG?: boolean
readonly VITE_IS_ONLINE: boolean
readonly VITE_IS_ONLINE: boolean
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv
}

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1697730408,
"narHash": "sha256-Ww//zzukdTrwTrCUkaJA/NsaLEfUfQpWZXBdXBYfhak=",
"lastModified": 1664753041,
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ff0a5a776b56e0ca32d47a4a47695452ec7f7d80",
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github"
},
"original": {

View File

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

View File

@ -9,7 +9,9 @@
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/>
<!--__vite-plugin-inject-preload__-->
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans[wght].woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans-Italic[wght].woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/Quicksand[wght].woff2" as="font">
</head>
<body>
<noscript>
@ -18,22 +20,15 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
//
//
// This variable points the frontend to the api.
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = 'http://localhost:3456/api/v1'
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
// our sentry instance to notify us of potential problems.
window.SENTRY_ENABLED = false
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
// This setting might change in the future or be removed completely.
window.PROJECT_INFINITE_NESTING_ENABLED = false
// Allow changing the logo and other icons based on various occasions throughout the year.
window.ALLOW_ICON_CHANGES = true
// Allow using a custom logo via external URL.
window.CUSTOM_LOGO_URL = ''
</script>
</body>
</html>

115
nginx.conf Normal file
View File

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

Binary file not shown.

Binary file not shown.

View File

@ -1,27 +1,7 @@
{
"name": "vikunja-frontend",
"description": "The todo app to organize your life.",
"private": true,
"version": "0.10.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
"url": "https://kolaente.dev/vikunja/frontend"
},
"bugs": {
"url": "https://kolaente.dev/vikunja/frontend/issues"
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.10.5",
"keywords": [
"todo",
"productivity",
"task management",
"organisation",
"gantt",
"kanban"
],
"private": true,
"scripts": {
"serve": "vite",
"preview": "vite preview --port 4173",
@ -34,7 +14,7 @@
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest --dir ./src",
"test:unit": "vitest",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "pnpm dlx browserslist@latest --update-db",
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
@ -45,142 +25,104 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/vue-fontawesome": "3.0.5",
"@github/hotkey": "2.3.0",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "1.5.0",
"@kyvg/vue3-notification": "3.0.2",
"@sentry/tracing": "7.80.1",
"@sentry/vue": "7.80.1",
"@tiptap/core": "2.1.12",
"@tiptap/extension-blockquote": "2.1.12",
"@tiptap/extension-bold": "2.1.12",
"@tiptap/extension-bullet-list": "2.1.12",
"@tiptap/extension-code": "2.1.12",
"@tiptap/extension-code-block-lowlight": "2.1.12",
"@tiptap/extension-document": "2.1.12",
"@tiptap/extension-dropcursor": "2.1.12",
"@tiptap/extension-gapcursor": "2.1.12",
"@tiptap/extension-hard-break": "2.1.12",
"@tiptap/extension-heading": "2.1.12",
"@tiptap/extension-history": "2.1.12",
"@tiptap/extension-horizontal-rule": "2.1.12",
"@tiptap/extension-image": "2.1.12",
"@tiptap/extension-italic": "2.1.12",
"@tiptap/extension-link": "2.1.12",
"@tiptap/extension-list-item": "2.1.12",
"@tiptap/extension-ordered-list": "2.1.12",
"@tiptap/extension-paragraph": "2.1.12",
"@tiptap/extension-placeholder": "2.1.12",
"@tiptap/extension-strike": "2.1.12",
"@tiptap/extension-table": "2.1.12",
"@tiptap/extension-table-cell": "2.1.12",
"@tiptap/extension-table-header": "2.1.12",
"@tiptap/extension-table-row": "2.1.12",
"@tiptap/extension-task-item": "2.1.12",
"@tiptap/extension-task-list": "2.1.12",
"@tiptap/extension-text": "2.1.12",
"@tiptap/extension-typography": "2.1.12",
"@tiptap/extension-underline": "2.1.12",
"@tiptap/pm": "2.1.12",
"@tiptap/suggestion": "2.1.12",
"@tiptap/vue-3": "2.1.12",
"@types/is-touch-device": "1.0.2",
"@types/lodash.clonedeep": "4.5.9",
"@types/sortablejs": "1.15.5",
"@vueuse/core": "10.5.0",
"@vueuse/router": "10.5.0",
"axios": "1.6.0",
"blurhash": "2.0.5",
"@fortawesome/fontawesome-svg-core": "6.2.1",
"@fortawesome/free-regular-svg-icons": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/vue-fontawesome": "3.0.2",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.3",
"@intlify/unplugin-vue-i18n": "0.8.1",
"@kyvg/vue3-notification": "2.7.0",
"@sentry/tracing": "7.29.0",
"@sentry/vue": "7.29.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.10.0",
"axios": "1.2.2",
"blurhash": "2.0.4",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"date-fns": "2.30.0",
"dayjs": "1.11.10",
"dompurify": "3.0.6",
"codemirror": "5.65.11",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dompurify": "2.4.2",
"easymde": "2.18.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
"floating-vue": "2.0.0-beta.24",
"floating-vue": "2.0.0-beta.20",
"focus-within": "3.0.2",
"highlight.js": "11.7.0",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"lowlight": "2.9.0",
"pinia": "2.1.7",
"marked": "4.2.5",
"minimist": "1.2.7",
"pinia": "2.0.28",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"tippy.js": "6.3.7",
"ufo": "1.3.2",
"vue": "3.3.8",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "9.7.0",
"vue-router": "4.2.5",
"workbox-precaching": "7.0.0",
"ufo": "1.0.1",
"vue": "3.2.45",
"vue-advanced-cropper": "2.8.6",
"vue-flatpickr-component": "11.0.1",
"vue-i18n": "9.2.2",
"vue-router": "4.1.6",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.5",
"@cypress/vite-dev-server": "5.0.6",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.3.1",
"@histoire/plugin-screenshot": "0.17.0",
"@histoire/plugin-vue": "0.17.5",
"@rushstack/eslint-patch": "1.5.1",
"@tsconfig/node18": "18.2.2",
"@types/codemirror": "5.60.13",
"@types/dompurify": "3.0.5",
"@types/flexsearch": "0.7.6",
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.9.1",
"@4tw/cypress-drag-drop": "2.2.3",
"@cypress/vite-dev-server": "5.0.2",
"@cypress/vue": "5.0.3",
"@faker-js/faker": "7.6.0",
"@histoire/plugin-screenshot": "0.12.4",
"@histoire/plugin-vue": "0.12.4",
"@rushstack/eslint-patch": "1.2.0",
"@types/codemirror": "5.60.6",
"@types/dompurify": "2.4.0",
"@types/flexsearch": "0.7.3",
"@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.8",
"@types/node": "18.11.18",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.5",
"@typescript-eslint/eslint-plugin": "6.11.0",
"@typescript-eslint/parser": "6.11.0",
"@vitejs/plugin-legacy": "4.1.1",
"@vitejs/plugin-vue": "4.5.0",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.4.2",
"@vue/tsconfig": "0.4.0",
"autoprefixer": "10.4.16",
"browserslist": "4.22.1",
"caniuse-lite": "1.0.30001563",
"css-has-pseudo": "6.0.0",
"csstype": "3.1.2",
"cypress": "13.5.1",
"esbuild": "0.19.5",
"eslint": "8.53.0",
"eslint-plugin-vue": "9.18.1",
"happy-dom": "12.10.3",
"histoire": "0.17.5",
"postcss": "8.4.31",
"@typescript-eslint/eslint-plugin": "5.48.0",
"@typescript-eslint/parser": "5.48.0",
"@vitejs/plugin-legacy": "3.0.1",
"@vitejs/plugin-vue": "4.0.0",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.2.7",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.13",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001441",
"csstype": "3.1.1",
"cypress": "12.3.0",
"esbuild": "0.16.14",
"eslint": "8.31.0",
"eslint-plugin-vue": "9.8.0",
"happy-dom": "8.1.1",
"histoire": "0.12.4",
"netlify-cli": "12.5.0",
"postcss": "8.4.20",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.0",
"postcss-preset-env": "9.3.0",
"rollup": "4.4.1",
"rollup-plugin-visualizer": "5.9.2",
"sass": "1.69.5",
"start-server-and-test": "2.0.3",
"typescript": "5.2.2",
"vite": "4.5.0",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.16.7",
"vite-plugin-sentry": "1.3.0",
"postcss-easings": "3.0.1",
"postcss-preset-env": "7.8.3",
"rollup": "3.9.1",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.57.1",
"start-server-and-test": "1.15.2",
"typescript": "4.9.4",
"vite": "4.0.4",
"vite-plugin-pwa": "0.14.1",
"vite-svg-loader": "4.0.0",
"vitest": "0.34.6",
"vue-tsc": "1.8.22",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
"vitest": "0.26.3",
"vue-tsc": "1.0.22",
"wait-on": "7.0.1",
"workbox-cli": "6.5.4"
},
"pnpm": {
"patchedDependencies": {
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
}
}
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.22.0"
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,11 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"labels": ["dependencies"],
"extends": [
"config:js-app"
"config:base"
],
"packageRules": [
{
"matchPackageNames": ["happy-dom"],
"matchPackageNames": ["netlify-cli", "happy-dom"],
"extends": ["schedule:weekly"]
},
{
@ -20,24 +20,11 @@
"@vueuse/"
]
},
{
"groupName": "histoire",
"matchPackagePrefixes": [
"@histoire/",
"histoire"
]
},
{
"groupName": "tiptap",
"matchPackagePrefixes": [
"@tiptap/",
"tiptap"
]
},
{
"matchDepTypes": ["devDependencies"],
"groupName": "dev-dependencies",
"extends": ["schedule:daily"]
"automerge": true,
"automergeStrategy": "squash",
"automergeType": "pr"
}
]
}
}

View File

@ -1,4 +1,4 @@
import { exec } from 'node:child_process'
const { exec } = require('child_process')
function createSlug(string) {
return String(string)
@ -33,9 +33,9 @@ const promiseExec = cmd => {
}
(async function () {
let stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify link --id ${siteId}`)
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
console.log(stdout)
stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify deploy --alias ${alias}`)
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
console.log(stdout)
const data = await fetch(prIssueCommentsUrl).then(response => response.json())

View File

@ -0,0 +1 @@
24df869e7a9282c76c9e1883071a39c0b11a53a57da68b37f2b918df25b1ae0f1b403e38a29c9cb694575bb9a7b52b6e ./scripts/deploy-preview-netlify.js

View File

@ -1 +0,0 @@
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs

28
scripts/run.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
echo "Using $VIKUNJA_API_URL as default api url"
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
sed -i "s/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
# Set the uid and gid of the nginx run user
usermod --non-unique --uid ${PUID} nginx
groupmod --non-unique --gid ${PGID} nginx
nginx -g "daemon off;"

View File

@ -8,15 +8,9 @@
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
<Teleport to="body">
<AddToHomeScreen/>
<UpdateNotification/>
<Notification/>
<DemoMode/>
</Teleport>
</ready>
</template>
@ -25,28 +19,23 @@ import {computed, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import UpdateNotification from '@/components/home/UpdateNotification.vue'
import KeyboardShortcuts from '@/components/misc/keyboard-shortcuts/index.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 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 {setLanguage} from '@/i18n'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
import {useAuthStore} from './stores/auth'
const baseStore = useBaseStore()
const authStore = useAuthStore()
@ -96,7 +85,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage(authStore.settings.language)
setLanguage()
useColorScheme()
</script>

Binary file not shown.

View File

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

Before

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 519 KiB

View File

@ -63,7 +63,7 @@ import {unrefElement} from '@vueuse/core'
import {ref, type HTMLAttributes} from 'vue'
import type {RouteLocationRaw} from 'vue-router'
export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
export interface BaseButtonProps extends HTMLAttributes {
type?: BaseButtonTypes
disabled?: boolean
to?: RouteLocationRaw

View File

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

View File

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

View File

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

View File

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

View File

@ -75,7 +75,6 @@ import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
@ -121,9 +120,9 @@ watch(
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 = parseDateOrString(from.value, false)
const dateTo = parseDateOrString(to.value, false)
if (dateFrom instanceof Date && dateTo instanceof Date) {
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}`
}
},

View File

@ -1,80 +0,0 @@
<template>
<div
v-if="shouldShowMessage"
class="add-to-home-screen"
:class="{'has-update-available': hasUpdateAvailable}"
>
<icon icon="arrow-up-from-bracket" class="add-icon"/>
<p>
{{ $t('home.addToHomeScreen') }}
</p>
<BaseButton @click="() => hideMessage = true" class="hide-button">
<icon icon="x"/>
</BaseButton>
</div>
</template>
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
import {useLocalStorage} from '@vueuse/core'
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
const baseStore = useBaseStore()
const hideMessage = useLocalStorage('hideAddToHomeScreenMessage', false)
const hasUpdateAvailable = computed(() => baseStore.updateAvailable)
const shouldShowMessage = computed(() => {
if (hideMessage.value) {
return false
}
if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) {
return false
}
return true
})
</script>
<style lang="scss" scoped>
.add-to-home-screen {
position: fixed;
// FIXME: We should prevent usage of z-index or
// at least define it centrally
// the highest z-index of a modal is .hint-modal with 4500
z-index: 5000;
bottom: 1rem;
inset-inline: 1rem;
max-width: max-content;
margin-inline: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .5rem 1rem;
background: var(--grey-900);
border-radius: $radius;
font-size: .9rem;
color: var(--grey-200);
@media screen and (min-width: $tablet) {
display: none;
}
&.has-update-available {
bottom: 5rem;
}
}
.add-icon {
color: var(--primary-light);
}
.hide-button {
padding: .25rem .5rem;
cursor: pointer;
}
</style>

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useConfigStore} from '@/stores/config'
import BaseButton from '@/components/base/BaseButton.vue'
const configStore = useConfigStore()
const hide = ref(false)
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
</script>
<template>
<div
v-if="enabled"
class="demo-mode-banner"
>
<p>
{{ $t('demo.title') }}
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</p>
<BaseButton @click="() => hide = true" class="hide-button">
<icon icon="times"/>
</BaseButton>
</div>
</template>
<style scoped lang="scss">
.demo-mode-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--danger);
z-index: 100;
padding: .5rem;
text-align: center;
&, strong {
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
}
}
.hide-button {
padding: .25rem .5rem;
cursor: pointer;
position: absolute;
right: .5rem;
top: .25rem;
}
</style>

View File

@ -4,26 +4,17 @@ import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
import {MILLISECONDS_A_HOUR} from '@/constants/date'
const now = useNow({
interval: MILLISECONDS_A_HOUR,
})
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
</script>
<template>
<div>
<Logo v-if="!CustomLogo" alt="Vikunja" class="logo" />
<img v-show="CustomLogo" :src="CustomLogo" alt="Vikunja" class="logo" />
</div>
<Logo alt="Vikunja" class="logo" />
</template>
<style lang="scss" scoped>
.logo {
color: var(--logo-text-color);
max-width: 168px;
max-height: 48px;
}
</style>

View File

@ -3,7 +3,7 @@
class="menu-show-button"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
v-shortcut="'Mod+e'"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
/>

View File

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

View File

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

View File

@ -1,94 +1,119 @@
<template>
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation"
class="navbar d-print-none">
<router-link :to="{ name: 'home' }" class="logo-link">
<Logo width="164" height="48" />
<header
:class="{'has-background': background, 'menu-active': menuActive}"
aria-label="main navigation"
class="navbar main-theme is-fixed-top d-print-none"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button"/>
<div class="list-title" ref="listTitle" v-show="currentList.id">
<template v-if="currentList.id">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<MenuButton class="menu-button" />
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
<div v-if="currentProject?.id" class="project-title-wrapper">
<h1 class="project-title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
<icon icon="circle-info" />
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown" :project="currentProject">
<template #trigger="{ toggleOpen }">
<BaseButton class="project-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon" />
</BaseButton>
</template>
</project-settings-dropdown>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</template>
</div>
<div class="navbar-end">
<OpenQuickActions/>
<Notifications />
<dropdown>
<template #trigger="{ toggleOpen, open }">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" />
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small" :style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)',
}">
<icon icon="chevron-down" />
</span>
</BaseButton>
</template>
<update/>
<BaseButton
@click="openQuickActions"
class="trigger-button pr-0"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
<notifications/>
<div class="user">
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger="{toggleOpen}">
<x-button
class="username-dropdown-trigger"
@click="toggleOpen()"
variant="secondary"
:shadow="false"
>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
</x-button>
</template>
<dropdown-item :to="{ name: 'user.settings' }">
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item v-if="privacyPolicyUrl" :href="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item :to="{ name: 'about' }">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
<dropdown-item
:to="{name: 'user.settings'}"
>
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item
v-if="imprintUrl"
:href="imprintUrl"
>
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl"
>
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="baseStore.setKeyboardShortcutsActive(true)"
>
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item
:to="{name: 'about'}"
>
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item
@click="authStore.logout()"
>
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {ref, computed, onMounted, nextTick} from 'vue'
import { RIGHTS as Rights } from '@/constants/rights'
import {RIGHTS as Rights} from '@/constants/rights'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
import { getProjectTitle } from '@/helpers/getProjectTitle'
import {getListTitle} from '@/helpers/getListTitle'
import { useBaseStore } from '@/stores/base'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
@ -96,149 +121,183 @@ const authStore = useAuthStore()
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 openQuickActions() {
baseStore.setQuickActionsActive(true)
}
</script>
<style lang="scss" scoped>
$vikunja-nav-logo-full-width: 164px;
$user-dropdown-width-mobile: 5rem;
.navbar {
--navbar-button-min-width: 40px;
--navbar-gap-width: 1rem;
--navbar-icon-size: 1.25rem;
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
gap: var(--navbar-gap-width);
background: var(--site-background);
@media screen and (max-width: $tablet) {
padding-right: .5rem;
}
@media screen and (min-width: $tablet) {
padding-left: 2rem;
padding-right: 1rem;
align-items: stretch;
}
&.menu-active {
@media screen and (max-width: $tablet) {
z-index: 0;
}
}
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
:deep() {
.trigger-button {
color: var(--grey-400);
font-size: var(--navbar-icon-size);
}
}
}
$hamburger-menu-icon-spacing: 1rem;
$hamburger-menu-icon-width: 28px;
.logo-link {
display: none;
padding: 0.5rem 0.75rem;
@media screen and (min-width: $tablet) {
align-self: stretch;
display: flex;
align-items: center;
margin-right: .5rem;
padding-left: 2rem;
margin-right: 1.5rem;
}
}
.menu-button {
align-self: stretch;
margin-right: auto;
align-self: stretch;
flex: 0 0 auto;
@media screen and (max-width: $tablet) {
margin-left: 1rem;
margin-left: $hamburger-menu-icon-spacing;
}
}
.project-title-wrapper {
margin-inline: auto;
display: flex;
.navbar.main-theme {
background: var(--site-background);
justify-content: space-between;
align-items: center;
// this makes the truncated text of the project title work
// inside the flexbox parent
min-width: 0;
@media screen and (min-width: $tablet) {
padding-inline: var(--navbar-gap-width);
@media screen and (max-width: $desktop) {
display: flex;
justify-content: space-between;
}
}
.project-title {
font-size: 1rem;
// We need the following for overflowing ellipsis to work
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@media screen and (min-width: $tablet) {
.title {
margin: 0;
font-size: 1.75rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.project-title-dropdown {
align-self: stretch;
.project-title-button {
flex-grow: 1;
.navbar-end {
margin-left: 0;
align-items: center;
display: flex;
}
}
.project-title-button {
align-self: stretch;
min-width: var(--navbar-button-min-width);
display: flex;
place-items: center;
justify-content: center;
font-size: var(--navbar-icon-size);
color: var(--grey-400);
}
.navbar-end {
margin-left: auto;
flex: 0 0 auto;
display: flex;
align-items: stretch;
>* {
min-width: var(--navbar-button-min-width);
}
}
.username-dropdown-trigger {
padding-left: 1rem;
display: inline-flex;
align-items: center;
text-transform: uppercase;
font-size: .85rem;
font-weight: 700;
}
.username {
font-family: $vikunja-font;
@media screen and (max-width: $tablet) {
display: none;
&.menu-active {
z-index: 0;
}
.user {
width: $user-dropdown-width-mobile;
.username-dropdown-trigger {
line-height: 1;
padding: 0 0.25rem;
height: 1rem;
.icon {
width: .5rem;
}
}
.username {
display: none;
}
}
}
}
.avatar {
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
.navbar {
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
:deep() {
.trigger-button {
cursor: pointer;
color: var(--grey-400);
padding: .5rem;
font-size: 1.25rem;
position: relative;
}
> * > .trigger-button {
width: $navbar-icon-width;
}
}
.user {
display: flex;
align-items: center;
span {
font-family: $vikunja-font;
}
.avatar {
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
}
.username-dropdown-trigger {
background: none;
&:focus:not(:active), &:active {
outline: none !important;
box-shadow: none !important;
}
}
}
}
.list-title {
display: flex;
align-items: center;
justify-content: center;
$edit-icon-width: 1rem;
@media screen and (min-width: $tablet) {
// We need a fixed width for overflowing ellipsis to work
--nav-username-width: 0;
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
}
@media screen and (max-width: $tablet) {
// We need a fixed width for overflowing ellipsis to work
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
}
h1 {
margin: 0;
}
:deep(.dropdown-trigger) {
color: var(--grey-400);
margin-left: .5rem;
height: 1rem;
width: 1rem;
cursor: pointer;
}
}
.info-button {
text-align: center;
height: 1.25rem;
line-height: 1.25rem;
width: 2rem;
margin-top: .25rem;
padding: 0 .5rem;
color: var(--grey-400);
margin-left: .5rem;
}
</style>

View File

@ -1,77 +0,0 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p class="update-notification__message">{{ $t('update.available') }}</p>
<x-button
@click="refreshApp()"
:shadow="false"
:wrap="false"
>
{{ $t('update.do') }}
</x-button>
</div>
</template>
<script lang="ts" setup>
import {computed, ref} from 'vue'
import {useBaseStore} from '@/stores/base'
const baseStore = useBaseStore()
const updateAvailable = computed(() => baseStore.updateAvailable)
const registration = ref(null)
const refreshing = ref(false)
document.addEventListener('swUpdated', showRefreshUI, {once: true})
navigator?.serviceWorker?.addEventListener(
'controllerchange', () => {
if (refreshing.value) return
refreshing.value = true
window.location.reload()
},
)
function showRefreshUI(e: Event) {
console.log('recieved refresh event', e)
registration.value = e.detail
baseStore.setUpdateAvailable(true)
}
function refreshApp() {
baseStore.setUpdateAvailable(false)
if (!registration.value || !registration.value.waiting) {
return
}
// Notify the service worker to actually do the update
registration.value.waiting.postMessage('skipWaiting')
}
</script>
<style lang="scss" scoped>
.update-notification {
position: fixed;
// FIXME: We should prevent usage of z-index or
// at least define it centrally
// the highest z-index of a modal is .hint-modal with 4500
z-index: 5000;
bottom: 1rem;
inset-inline: 1rem;
max-width: max-content;
margin-inline: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: .5rem .5rem .5rem 1rem;
background: $warning;
border-radius: $radius;
font-size: .9rem;
color: hsl(220.9, 39.3%, 11%); // color copied to avoid it changing in dark mode
}
.update-notification__message {
width: 100%;
text-align: center;
}
</style>

View File

@ -1,16 +1,16 @@
<template>
<div class="content-auth">
<BaseButton
v-show="menuActive"
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
</BaseButton>
<div
class="app-container"
:class="{'has-background': background || blurHash}"
:style="{'background-image': blurHash && `url(${blurHash})`}"
class="app-container"
>
<div
:class="{'is-visible': background}"
@ -18,14 +18,14 @@
:style="{'background-image': background && `url(${background})`}"></div>
<navigation class="d-print-none"/>
<main
class="app-content"
:class="[
{ 'is-menu-enabled': menuActive },
$route.name,
]"
class="app-content"
>
<BaseButton
v-show="menuActive"
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
/>
@ -33,13 +33,13 @@
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
<modal
:enabled="typeof currentModal !== 'undefined'"
:enabled="Boolean(currentModal)"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
@ -69,7 +69,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
@ -87,26 +86,30 @@ function showKeyboardShortcuts() {
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 project highlight in menu if the current route is not project related.
// Reset the current list highlight in menu if the current route is not list related.
watch(() => route.name as string, (routeName) => {
if (
routeName &&
(
[
'home',
'namespace.edit',
'teams.index',
'teams.edit',
'tasks.range',
'labels.index',
'migrate.start',
'migrate.wunderlist',
'projects.index',
'namespaces.index',
].includes(routeName) ||
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentProject({project: null})
baseStore.handleSetCurrentList({list: null})
}
})
@ -116,9 +119,6 @@ useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const projectStore = useProjectStore()
projectStore.loadProjects()
</script>
<style lang="scss" scoped>
@ -143,6 +143,7 @@ projectStore.loadProjects()
&:hover,
&:focus {
height: 1rem;
color: var(--grey-600);
}
}
@ -159,8 +160,6 @@ projectStore.loadProjects()
z-index: 10;
position: relative;
padding: 1.5rem 0.5rem 1rem;
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
transition: margin-left $transition-duration;
@media screen and (max-width: $tablet) {
margin-left: 0;
@ -226,4 +225,9 @@ projectStore.loadProjects()
position: relative;
z-index: 1;
}
.is-touch .content-auth,
.content-auth.z-unset {
z-index: unset;
}
</style>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
export default TipTap
export default createAsyncComponent(() => import('@/components/input/editor.vue'))

View File

@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {computed, ref, toRef, watch} from 'vue'
import {createRandomID} from '@/helpers/randomId'
import XButton from '@/components/input/button.vue'
@ -53,16 +53,22 @@ const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
const {
modelValue,
} = defineProps<{
modelValue: string,
}>()
const props = defineProps({
modelValue: {
type: String,
required: true,
},
menuPosition: {
type: String,
default: 'top',
},
})
const emit = defineEmits(['update:modelValue'])
const modelValue = toRef(props, 'modelValue')
watch(
() => modelValue,
modelValue,
(newValue) => {
color.value = newValue
},

View File

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

View File

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

View File

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

View File

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

View File

@ -8,20 +8,17 @@
'has-no-shadow': !shadow || variant === 'tertiary',
}
]"
:style="{
'--button-white-space': wrap ? 'break-spaces' : 'nowrap',
}"
>
<template v-if="icon">
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<span class="icon is-small" v-else>
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
</span>
</template>
@ -47,13 +44,12 @@ import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
// extending the props of the BaseButton
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
export interface ButtonProps extends BaseButtonProps {
variant?: ButtonTypes
icon?: IconProp
iconColor?: string
loading?: boolean
shadow?: boolean
wrap?: boolean
}
const {
@ -62,7 +58,6 @@ const {
iconColor = '',
loading = false,
shadow = true,
wrap = true,
} = defineProps<ButtonProps>()
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
@ -82,7 +77,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
white-space: break-spaces;
&:hover {
box-shadow: var(--shadow-md);
@ -104,6 +99,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
&.is-primary.is-outlined:hover {
color: var(--white);
}
}
.is-small {

View File

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

View File

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

View File

@ -0,0 +1,446 @@
<template>
<div class="editor">
<div class="clear"></div>
<vue-easymde
:configs="config"
@change="() => bubble()"
@update:modelValue="handleInput"
class="content"
v-if="isEditActive"
v-model="text"/>
<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
</div>
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
{{ emptyText }}
<template v-if="isEditEnabled">
<ButtonLink
@click="toggleEdit"
v-shortcut="editShortcut"
class="d-print-none">
{{ $t('input.editor.edit') }}
</ButtonLink>.
</template>
</p>
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
<li v-if="isEditEnabled && !showPreviewText && showSave">
<BaseButton
v-if="showEditButton"
@click="toggleEdit"
v-shortcut="editShortcut">
{{ $t('input.editor.edit') }}
</BaseButton>
<BaseButton
v-else-if="isEditActive"
@click="toggleEdit"
class="done-edit">
{{ $t('misc.save') }}
</BaseButton>
</li>
<li v-for="(action, k) in bottomActions" :key="k">
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
</li>
</ul>
<template v-else-if="isEditEnabled && showSave">
<ul v-if="showEditButton" class="actions d-print-none">
<li>
<BaseButton
@click="toggleEdit"
v-shortcut="editShortcut">
{{ $t('input.editor.edit') }}
</BaseButton>
</li>
</ul>
<x-button
v-else-if="isEditActive"
@click="toggleEdit"
variant="secondary"
:shadow="false"
v-cy="'saveEditor'">
{{ $t('misc.save') }}
</x-button>
</template>
</div>
</template>
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
import VueEasymde from './vue-easymde.vue'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import {createEasyMDEConfig} from './editorConfig'
import AttachmentModel from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
import {findCheckboxesInText} from '@/helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import type { IAttachment } from '@/modelTypes/IAttachment'
import type { ITask } from '@/modelTypes/ITask'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
uploadEnabled: {
type: Boolean,
default: false,
},
uploadCallback: {
type: Function,
},
hasPreview: {
type: Boolean,
default: true,
},
previewIsDefault: {
type: Boolean,
default: true,
},
isEditEnabled: {
default: true,
},
bottomActions: {
type: Array,
default: () => [],
},
emptyText: {
type: String,
default: '',
},
showSave: {
type: Boolean,
default: false,
},
// If a key is passed the editor will go in "edit" mode when the key is pressed.
// Disabled if an empty string is passed.
editShortcut: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const text = ref('')
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const isEditActive = ref(false)
const isPreviewActive = ref(true)
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
const preview = ref('')
const attachmentService = new AttachmentService()
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
const loadedAttachments = ref<{[key: CacheKey]: string}>({})
const config = ref(createEasyMDEConfig({
placeholder: props.placeholder,
uploadImage: props.uploadEnabled,
imageUploadFunction: props.uploadCallback,
}))
const checkboxId = ref(createRandomID())
const {modelValue} = toRefs(props)
watch(
modelValue,
async (value) => {
text.value = value
await nextTick()
renderPreview()
},
)
watch(
text,
(newVal, oldVal) => {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && text.value === modelValue.value) {
return
}
bubble()
},
)
onMounted(() => {
if (modelValue.value !== '') {
text.value = modelValue.value
}
if (props.previewIsDefault && props.hasPreview) {
nextTick(() => renderPreview())
return
}
isPreviewActive.value = false
isEditActive.value = true
})
// This gets triggered when only pasting content into the editor.
// A change event would not get generated by that, an input event does.
// Therefore, we're using this handler to catch paste events.
// But because this also gets triggered when typing into the editor, we give
// it a higher timeout to make the timouts cancel each other in that case so
// that in the end, only one change event is triggered to the outside per change.
function handleInput(val: string) {
// Don't bubble if the text is up to date
if (val === text.value) {
return
}
text.value = val
bubble(1000)
}
function bubble(timeout = 500) {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
changeTimeout.value = setTimeout(() => {
emit('update:modelValue', text.value)
}, timeout)
}
function replaceAt(str: string, index: number, replacement: string) {
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
}
function findNthIndex(str: string, n: number) {
const checkboxes = findCheckboxesInText(str)
return checkboxes[n]
}
function renderPreview() {
setupMarkdownRenderer(checkboxId.value)
preview.value = DOMPurify.sanitize(marked(text.value), {ADD_ATTR: ['target']})
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
// To work around this, we modify the url after rendering it in the vue component.
// We're doing the whole thing in the next tick to ensure the image elements are available in the
// dom tree. If we're calling this right after setting this.preview it could be the images were
// not already made available.
// Some docs at https://stackoverflow.com/q/62865160/10924593
nextTick().then(async () => {
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
if (attachmentImage) {
Array.from(attachmentImage).forEach(async (img) => {
// The url is something like /tasks/<id>/attachments/<id>
const parts = img.dataset.src?.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
img.src = loadedAttachments.value[cacheKey]
return
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
const url = await attachmentService.getBlobUrl(attachment)
img.src = url
loadedAttachments.value[cacheKey] = url
})
}
const textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
if (textCheckbox) {
Array.from(textCheckbox).forEach(check => {
check.removeEventListener('change', handleCheckboxClick)
check.addEventListener('change', handleCheckboxClick)
check.parentElement?.classList.add('has-checkbox')
})
}
})
}
function handleCheckboxClick(e: Event) {
// Find the original markdown checkbox this is targeting
const checked = (e.target as HTMLInputElement).checked
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
const index = findNthIndex(text.value, numMarkdownCheck)
if (index < 0 || typeof index === 'undefined') {
console.debug('no index found')
return
}
const listPrefix = text.value.substring(index, index + 1)
console.debug({index, listPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}
function toggleEdit() {
if (isEditActive.value) {
isPreviewActive.value = true
isEditActive.value = false
renderPreview()
bubble(0) // save instantly
} else {
isPreviewActive.value = false
isEditActive.value = true
}
}
</script>
<style lang="scss">
@import 'codemirror/lib/codemirror.css';
@import 'highlight.js/scss/base16/equilibrium-gray-light';
.editor {
.clear {
clear: both;
}
.preview.content {
margin-bottom: .5rem;
overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
overflow-wrap: break-word;
ul li {
input[type="checkbox"] {
margin-right: .5rem;
}
&.has-checkbox {
margin-left: -1.25rem;
list-style: none;
}
}
}
}
.CodeMirror {
padding: .5rem;
border: 1px solid var(--grey-200) !important;
background: var(--white);
&-lines pre {
margin: 0 !important;
}
&-placeholder {
color: var(--grey-400) !important;
font-style: italic;
}
&-cursor {
border-color: var(--grey-700);
}
}
.editor-preview {
padding: 0;
&-side {
padding: .5rem;
}
}
.editor-toolbar {
background: var(--grey-50);
border: 1px solid var(--grey-200);
border-bottom: none;
button {
color: var(--grey-700);
&.active {
background: var(--grey-200);
}
svg {
vertical-align: middle;
&, rect {
width: 20px;
height: 20px;
}
}
&::after {
position: absolute;
top: 24px;
margin-left: -3px;
}
&:hover {
background: var(--grey-200);
border-color: var(--grey-300);
}
}
i.separator {
border-color: var(--grey-200) !important;
}
}
pre.CodeMirror-line {
margin-bottom: 0 !important;
color: var(--grey-700) !important;
}
.cm-header {
font-family: $vikunja-font;
font-weight: 400;
}
ul.actions {
font-size: .8rem;
margin: 0;
li {
display: inline-block;
&::after {
content: '·';
padding: 0 .25rem;
}
&:last-child:after {
content: '';
}
}
&, a {
color: var(--grey-500);
&.done-edit {
color: var(--primary);
}
}
a:hover {
text-decoration: underline;
}
}
.vue-easymde.content {
margin-bottom: 0 !important;
}
</style>

View File

@ -1,145 +0,0 @@
<template>
<div class="items">
<template v-if="items.length">
<button
class="item"
:class="{ 'is-selected': index === selectedIndex }"
v-for="(item, index) in items"
:key="index"
@click="selectItem(index)"
>
<icon :icon="item.icon"/>
<div class="description">
<p>{{ item.title }}</p>
<p>{{ item.description }}</p>
</div>
</button>
</template>
<div class="item" v-else>
No result
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({event}) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
selectItem(index) {
const item = this.items[index]
if (item) {
this.command(item)
}
},
},
}
</script>
<style lang="scss" scoped>
.items {
padding: 0.2rem;
position: relative;
border-radius: 0.5rem;
background: var(--white);
color: var(--grey-900);
overflow: hidden;
font-size: 0.9rem;
box-shadow: var(--shadow-md);
}
.item {
display: flex;
align-items: center;
margin: 0;
width: 100%;
text-align: left;
background: transparent;
border-radius: $radius;
border: 0;
padding: 0.2rem 0.4rem;
transition: background-color $transition;
&.is-selected, &:hover {
background: var(--grey-100);
cursor: pointer;
}
> svg {
box-sizing: border-box;
width: 2rem;
height: 2rem;
border: 1px solid var(--grey-300);
padding: .5rem;
margin-right: .5rem;
border-radius: $radius;
color: var(--grey-700);
}
}
.description {
display: flex;
flex-direction: column;
font-size: .9rem;
color: var(--grey-800);
p:last-child {
font-size: .75rem;
color: var(--grey-500);
}
}
</style>

View File

@ -1,439 +0,0 @@
<template>
<div class="editor-toolbar">
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
v-tooltip="$t('input.editor.heading1')"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<span class="icon__lower-text">1</span>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
v-tooltip="$t('input.editor.heading2')"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<span class="icon__lower-text">2</span>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
v-tooltip="$t('input.editor.heading3')"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<span class="icon__lower-text">3</span>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
v-tooltip="$t('input.editor.bold')"
>
<span class="icon">
<icon :icon="['fa', 'fa-bold']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
v-tooltip="$t('input.editor.italic')"
>
<span class="icon">
<icon :icon="['fa', 'fa-italic']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleUnderline().run()"
:class="{ 'is-active': editor.isActive('underline') }"
v-tooltip="$t('input.editor.underline')"
>
<span class="icon">
<icon :icon="['fa', 'fa-underline']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
v-tooltip="$t('input.editor.strikethrough')"
>
<span class="icon">
<icon :icon="['fa', 'fa-strikethrough']"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
v-tooltip="$t('input.editor.code')"
>
<span class="icon">
<icon :icon="['fa', 'fa-code']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
v-tooltip="$t('input.editor.quote')"
>
<span class="icon">
<icon :icon="['fa', 'fa-quote-right']"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
v-tooltip="$t('input.editor.bulletList')"
>
<span class="icon">
<icon :icon="['fa', 'fa-list-ol']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
v-tooltip="$t('input.editor.orderedList')"
>
<span class="icon">
<icon :icon="['fa', 'fa-list-ul']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleTaskList().run()"
:class="{ 'is-active': editor.isActive('taskList') }"
v-tooltip="$t('input.editor.taskList')"
>
<span class="icon">
<icon icon="fa-list-check"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="openImagePicker"
v-tooltip="$t('input.editor.image')"
>
<span class="icon">
<icon icon="fa-image"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="setLink"
:class="{ 'is-active': editor.isActive('link') }"
title="set link"
v-tooltip="$t('input.editor.link')"
>
<span class="icon">
<icon :icon="['fa', 'fa-link']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
title="paragraph"
v-tooltip="$t('input.editor.text')"
>
<span class="icon">
<icon :icon="['fa', 'fa-paragraph']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().setHorizontalRule().run()"
v-tooltip="$t('input.editor.horizontalRule')"
>
<span class="icon">
<icon :icon="['fa', 'fa-ruler-horizontal']"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().undo().run()"
v-tooltip="$t('input.editor.undo')"
>
<span class="icon">
<icon :icon="['fa', 'fa-undo']"/>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().redo().run()"
v-tooltip="$t('input.editor.redo')"
>
<span class="icon">
<icon :icon="['fa', 'fa-redo']"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<!-- table -->
<BaseButton
class="editor-toolbar__button"
@click="toggleTableMode"
:class="{ 'is-active': editor.isActive('table') }"
v-tooltip="$t('input.editor.table.title')"
>
<span class="icon">
<icon :icon="['fa', 'fa-table']"/>
</span>
</BaseButton>
<div v-if="tableMode" class="editor-toolbar__table-buttons">
<BaseButton
class="editor-toolbar__button"
@click="
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
"
>
{{ $t('input.editor.table.insert') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addColumnBefore().run()"
:disabled="!editor.can().addColumnBefore"
>
{{ $t('input.editor.table.addColumnBefore') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addColumnAfter().run()"
:disabled="!editor.can().addColumnAfter"
>
{{ $t('input.editor.table.addColumnAfter') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteColumn().run()"
:disabled="!editor.can().deleteColumn"
>
{{ $t('input.editor.table.deleteColumn') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addRowBefore().run()"
:disabled="!editor.can().addRowBefore"
>
{{ $t('input.editor.table.addRowBefore') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addRowAfter().run()"
:disabled="!editor.can().addRowAfter"
>
{{ $t('input.editor.table.addRowAfter') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteRow().run()"
:disabled="!editor.can().deleteRow"
>
{{ $t('input.editor.table.deleteRow') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteTable().run()"
:disabled="!editor.can().deleteTable"
>
{{ $t('input.editor.table.deleteTable') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().mergeCells().run()"
:disabled="!editor.can().mergeCells"
>
{{ $t('input.editor.table.mergeCells') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().splitCell().run()"
:disabled="!editor.can().splitCell"
>
{{ $t('input.editor.table.splitCell') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderColumn().run()"
:disabled="!editor.can().toggleHeaderColumn"
>
{{ $t('input.editor.table.toggleHeaderColumn') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderRow().run()"
:disabled="!editor.can().toggleHeaderRow"
>
{{ $t('input.editor.table.toggleHeaderRow') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderCell().run()"
:disabled="!editor.can().toggleHeaderCell"
>
{{ $t('input.editor.table.toggleHeaderCell') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().mergeOrSplit().run()"
:disabled="!editor.can().mergeOrSplit"
>
{{ $t('input.editor.table.mergeOrSplit') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().fixTables().run()"
:disabled="!editor.can().fixTables"
>
{{ $t('input.editor.table.fixTables') }}
</BaseButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {Editor} from '@tiptap/vue-3'
import BaseButton from '@/components/base/BaseButton.vue'
const {
editor = null,
} = defineProps<{
editor: Editor,
}>()
const tableMode = ref(false)
function toggleTableMode() {
tableMode.value = !tableMode.value
}
function openImagePicker() {
document.getElementById('tiptap__image-upload').click()
}
function setLink() {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// update link
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({href: url, target: '_blank'})
.run()
}
</script>
<style lang="scss" scoped>
.editor-toolbar {
background: var(--white);
border: 1px solid var(--grey-200);
user-select: none;
padding: .5rem;
border-radius: $radius;
display: flex;
flex-wrap: wrap;
> * + * {
border-left: 1px solid var(--grey-200);
margin-left: 6px;
padding-left: 6px;
}
}
.editor-toolbar__button {
min-width: 2rem;
height: 2rem;
border-radius: $radius;
border: 1px solid transparent;
color: var(--grey-700);
transition: all $transition;
background: transparent;
margin-right: .25rem;
&:hover {
background: var(--grey-100);
border-color: var(--grey-200);
}
.icon {
position: relative;
.icon__lower-text {
font-size: .75rem;
position: absolute;
bottom: -3px;
right: -2px;
font-weight: bold;
}
}
}
.editor-toolbar__table-buttons {
margin-top: .5rem;
> .editor-toolbar__button {
margin-right: .5rem;
margin-bottom: .5rem;
padding: 0 .25rem;
border: 1px solid var(--grey-400);
font-size: .75rem;
height: 1.5rem;
}
}
</style>

View File

@ -1,842 +0,0 @@
<template>
<div class="tiptap">
<EditorToolbar
v-if="editor && isEditing"
:editor="editor"
:upload-callback="uploadCallback"
/>
<BubbleMenu
v-if="editor && isEditing"
:editor="editor"
class="editor-bubble__wrapper"
>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
v-tooltip="$t('input.editor.bold')"
>
<icon :icon="['fa', 'fa-bold']"/>
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
v-tooltip="$t('input.editor.italic')"
>
<icon :icon="['fa', 'fa-italic']"/>
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleUnderline().run()"
:class="{ 'is-active': editor.isActive('underline') }"
v-tooltip="$t('input.editor.underline')"
>
<icon :icon="['fa', 'fa-underline']"/>
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
v-tooltip="$t('input.editor.strikethrough')"
>
<icon :icon="['fa', 'fa-strikethrough']"/>
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
v-tooltip="$t('input.editor.code')"
>
<icon :icon="['fa', 'fa-code']"/>
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="setLink"
:class="{ 'is-active': editor.isActive('link') }"
v-tooltip="$t('input.editor.link')"
>
<icon :icon="['fa', 'fa-link']"/>
</BaseButton>
</BubbleMenu>
<editor-content
class="tiptap__editor"
:class="{'tiptap__editor-is-empty': isEmpty, 'tiptap__editor-is-edit-enabled': isEditing}"
:editor="editor"
/>
<input
v-if="isEditing"
type="file"
id="tiptap__image-upload"
class="is-hidden"
ref="uploadInputRef"
@change="addImage"
/>
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length === 0 && !isEditing">
<li>
<BaseButton
@click="setEdit"
class="done-edit">
{{ $t('input.editor.edit') }}
</BaseButton>
</li>
</ul>
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length > 0">
<li v-if="isEditing && showSave">
<BaseButton
@click="bubbleSave"
class="done-edit">
{{ $t('misc.save') }}
</BaseButton>
</li>
<li v-if="!isEditing">
<BaseButton
@click="setEdit"
class="done-edit">
{{ $t('input.editor.edit') }}
</BaseButton>
</li>
<li v-for="(action, k) in bottomActions" :key="k">
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
</li>
</ul>
<x-button
v-else-if="isEditing && showSave"
class="mt-4"
@click="bubbleSave"
variant="secondary"
:shadow="false"
v-cy="'saveEditor'"
>
{{ $t('misc.save') }}
</x-button>
</div>
</template>
<script setup lang="ts">
import {ref, watch, onBeforeUnmount, nextTick, onMounted, computed} from 'vue'
import {refDebounced} from '@vueuse/core'
import EditorToolbar from './EditorToolbar.vue'
import Link from '@tiptap/extension-link'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import Table from '@tiptap/extension-table'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row'
import Typography from '@tiptap/extension-typography'
import Image from '@tiptap/extension-image'
import Underline from '@tiptap/extension-underline'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import {Blockquote} from '@tiptap/extension-blockquote'
import {Bold} from '@tiptap/extension-bold'
import {BulletList} from '@tiptap/extension-bullet-list'
import {Code} from '@tiptap/extension-code'
import {Document} from '@tiptap/extension-document'
import {Dropcursor} from '@tiptap/extension-dropcursor'
import {Gapcursor} from '@tiptap/extension-gapcursor'
import {HardBreak} from '@tiptap/extension-hard-break'
import {Heading} from '@tiptap/extension-heading'
import {History} from '@tiptap/extension-history'
import {HorizontalRule} from '@tiptap/extension-horizontal-rule'
import {Italic} from '@tiptap/extension-italic'
import {ListItem} from '@tiptap/extension-list-item'
import {OrderedList} from '@tiptap/extension-ordered-list'
import {Paragraph} from '@tiptap/extension-paragraph'
import {Strike} from '@tiptap/extension-strike'
import {Text} from '@tiptap/extension-text'
import {BubbleMenu, EditorContent, useEditor} from '@tiptap/vue-3'
import {Node} from '@tiptap/pm/model'
import Commands from './commands'
import suggestionSetup from './suggestion'
import {lowlight} from 'lowlight'
import type {BottomAction, UploadCallback} from './types'
import type {ITask} from '@/modelTypes/ITask'
import type {IAttachment} from '@/modelTypes/IAttachment'
import AttachmentModel from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import XButton from '@/components/input/button.vue'
import {Placeholder} from '@tiptap/extension-placeholder'
import {eventToHotkeyString} from '@github/hotkey'
import {mergeAttributes} from '@tiptap/core'
import {createRandomID} from '@/helpers/randomId'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
const {t} = useI18n()
const CustomTableCell = TableCell.extend({
addAttributes() {
return {
// extend the existing attributes
...this.parent?.(),
// and add a new one
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-background-color'),
renderHTML: (attributes) => {
return {
'data-background-color': attributes.backgroundColor,
style: `background-color: ${attributes.backgroundColor}`,
}
},
},
}
},
})
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
const loadedAttachments = ref<{ [key: CacheKey]: string }>({})
const CustomImage = Image.extend({
renderHTML({HTMLAttributes}) {
if (HTMLAttributes.src?.startsWith(window.API_URL)) {
const id = 'tiptap-image-' + createRandomID()
nextTick(async () => {
const img = document.getElementById(id)
if (!img) return
// The url is something like /tasks/<id>/attachments/<id>
const parts = img.dataset?.src.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
if (typeof loadedAttachments.value[cacheKey] === 'undefined') {
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
const attachmentService = new AttachmentService()
const url = await attachmentService.getBlobUrl(attachment)
loadedAttachments.value[cacheKey] = url
}
img.src = loadedAttachments.value[cacheKey]
})
return ['img', mergeAttributes(this.options.HTMLAttributes, {
'data-src': HTMLAttributes.src,
src: '#',
alt: HTMLAttributes.alt,
title: HTMLAttributes.title,
id,
})]
}
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},
})
type Mode = 'edit' | 'preview'
const {
modelValue,
uploadCallback,
isEditEnabled = true,
bottomActions = [],
showSave = false,
placeholder = '',
editShortcut = '',
initialMode = 'edit',
} = defineProps<{
modelValue: string,
uploadCallback?: UploadCallback,
isEditEnabled?: boolean,
bottomActions?: BottomAction[],
showSave?: boolean,
placeholder?: string,
editShortcut?: string,
initialMode?: Mode,
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const inputHTML = ref('')
const isEmpty = computed(() => isEditorContentEmpty(inputHTML.value))
const internalMode = ref<Mode>(initialMode)
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
const editor = useEditor({
content: modelValue,
editable: isEditing.value,
extensions: [
// Starterkit:
Blockquote,
Bold,
BulletList,
Code,
CodeBlockLowlight.configure({
lowlight,
}),
Document,
Dropcursor,
Gapcursor,
HardBreak.extend({
addKeyboardShortcuts() {
return {
'Mod-Enter': () => {
bubbleSave()
},
}
},
}),
Heading,
History,
HorizontalRule,
Italic,
ListItem,
OrderedList,
Paragraph,
Strike,
Text,
Placeholder.configure({
placeholder: ({editor}) => {
if (!isEditing.value) {
return ''
}
if (editor.getText() !== '' && !editor.isFocused) {
return ''
}
return placeholder !== ''
? placeholder
: t('input.editor.placeholder')
},
}),
Typography,
Underline,
Link.configure({
openOnClick: true,
validate: (href: string) => /^https?:\/\//.test(href),
}),
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
// Custom TableCell with backgroundColor attribute
CustomTableCell,
CustomImage,
TaskList,
TaskItem.configure({
nested: true,
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
if (isEditEnabled) {
node.attrs.checked = checked
inputHTML.value = editor.value?.getHTML()
bubbleSave()
return true
}
return false
},
}),
Commands.configure({
suggestion: suggestionSetup(t),
}),
BubbleMenu,
],
onUpdate: () => {
inputHTML.value = editor.value!.getHTML()
},
})
watch(
() => modelValue,
value => {
inputHTML.value = value
if (!editor?.value) return
if (editor.value.getHTML() === value) {
return
}
editor.value.commands.setContent(value, false)
},
)
const debouncedInputHTML = refDebounced(inputHTML, 1000)
watch(debouncedInputHTML, () => bubbleNow())
function bubbleNow() {
emit('update:modelValue', inputHTML.value)
}
function bubbleSave() {
bubbleNow()
emit('save', inputHTML.value)
if (isEditing.value) {
internalMode.value = 'preview'
}
}
function setEdit() {
internalMode.value = 'edit'
editor.value?.commands.focus()
}
watch(
() => isEditing.value,
() => {
editor.value?.setEditable(isEditing.value)
},
)
onBeforeUnmount(() => editor.value?.destroy())
const uploadInputRef = ref<HTMLInputElement | null>(null)
function uploadAndInsertFiles(files: File[] | FileList) {
uploadCallback(files).then(urls => {
urls?.forEach(url => {
editor.value
?.chain()
.focus()
.setImage({src: url})
.run()
})
bubbleSave()
})
}
function addImage() {
if (typeof uploadCallback !== 'undefined') {
const files = uploadInputRef.value?.files
if (!files || files.length === 0) {
return
}
uploadAndInsertFiles(files)
return
}
const url = window.prompt('URL')
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()
bubbleSave()
}
}
function setLink() {
const previousUrl = editor.value?.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor.value
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
return
}
// update link
editor.value
?.chain()
.focus()
.extendMarkRange('link')
.setLink({href: url, target: '_blank'})
.run()
}
onMounted(() => {
internalMode.value = initialMode
document.addEventListener('paste', handleImagePaste)
if (editShortcut !== '') {
document.addEventListener('keydown', setFocusToEditor)
}
})
onBeforeUnmount(() => {
document.removeEventListener('paste', handleImagePaste)
if (editShortcut !== '') {
document.removeEventListener('keydown', setFocusToEditor)
}
})
function handleImagePaste(event) {
event.preventDefault()
event?.clipboardData?.items?.forEach(i => {
if (i.kind === 'file' && i.type.startsWith('image/')) {
uploadAndInsertFiles([i.getAsFile()])
}
})
}
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event) {
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString !== editShortcut ||
event.target.tagName.toLowerCase() === 'input' ||
event.target.tagName.toLowerCase() === 'textarea' ||
event.target.contentEditable === 'true') {
return
}
event.preventDefault()
if (initialMode === 'preview' && isEditEnabled && !isEditing.value) {
internalMode.value = 'edit'
}
editor.value?.commands.focus()
}
</script>
<style lang="scss">
.tiptap__editor {
&.tiptap__editor-is-edit-enabled {
min-height: 10rem;
}
transition: box-shadow $transition;
border-radius: $radius;
&:focus-within, &:focus {
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
}
}
.tiptap p.is-empty::before {
content: attr(data-placeholder);
color: var(--grey-400);
pointer-events: none;
height: 0;
float: left;
}
// Basic editor styles
.ProseMirror {
padding: .5rem;
&:focus-within, &:focus {
box-shadow: none;
}
> * + * {
margin-top: 0.75em;
}
ul,
ol {
padding: 0 1rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}
a {
color: #68cef8;
}
code {
background-color: rgba(#616161, 0.1);
color: #616161;
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
img {
max-width: 100%;
height: auto;
&.ProseMirror-selectednode {
outline: 3px solid #68cef8;
}
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
hr {
border: none;
border-top: 2px solid rgba(#0d0d0d, 0.1);
margin: 2rem 0;
}
}
.ProseMirror {
/* Table-specific styling */
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0;
overflow: hidden;
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
position: relative;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: '';
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background-color: #adf;
pointer-events: none;
}
p {
margin: 0;
}
}
// Lists
ul {
margin-left: .5rem;
margin-top: 0 !important;
li {
margin-top: 0;
}
p {
margin-bottom: 0 !important;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
// tasklist
ul[data-type='taskList'] {
list-style: none;
padding: 0;
margin-left: 0;
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
input[type='checkbox'] {
cursor: pointer;
}
}
.editor-bubble__wrapper {
background: var(--white);
border-radius: $radius;
border: 1px solid var(--grey-200);
box-shadow: var(--shadow-md);
display: flex;
overflow: hidden;
}
.editor-bubble__button {
color: var(--grey-700);
transition: all $transition;
background: transparent;
svg {
box-sizing: border-box;
display: block;
width: 1rem;
height: 1rem;
padding: .5rem;
margin: 0;
}
&:hover {
background: var(--grey-200);
}
}
ul.tiptap__editor-actions {
font-size: .8rem;
margin: 0;
li {
display: inline-block;
&::after {
content: '·';
padding: 0 .25rem;
}
&:last-child:after {
content: '';
}
}
&, a {
color: var(--grey-500);
&.done-edit {
color: var(--primary);
}
}
a:hover {
text-decoration: underline;
}
}
</style>

View File

@ -1,28 +0,0 @@
import {Extension} from '@tiptap/core'
import Suggestion from '@tiptap/suggestion'
// Copied and adjusted from https://github.com/ueberdosis/tiptap/tree/252acb32d27a0f9af14813eeed83d8a50059a43a/demos/src/Experiments/Commands/Vue
export default Extension.create({
name: 'slash-menu-commands',
addOptions() {
return {
suggestion: {
char: '/',
command: ({editor, range, props}) => {
props.command({editor, range})
},
},
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
]
},
})

View File

@ -1,214 +0,0 @@
import {VueRenderer} from '@tiptap/vue-3'
import tippy from 'tippy.js'
import CommandsList from './CommandsList.vue'
export default function suggestionSetup(t) {
return {
items: ({query}: { query: string }) => {
return [
{
title: t('input.editor.text'),
description: t('input.editor.textTooltip'),
icon: 'fa-font',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('paragraph', {level: 1})
.run()
},
},
{
title: t('input.editor.heading1'),
description: t('input.editor.heading1Tooltip'),
icon: 'fa-header',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', {level: 1})
.run()
},
},
{
title: t('input.editor.heading2'),
description: t('input.editor.heading2Tooltip'),
icon: 'fa-header',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', {level: 2})
.run()
},
},
{
title: t('input.editor.heading3'),
description: t('input.editor.heading3Tooltip'),
icon: 'fa-header',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', {level: 2})
.run()
},
},
{
title: t('input.editor.bulletList'),
description: t('input.editor.bulletListTooltip'),
icon: 'fa-list-ul',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleBulletList()
.run()
},
},
{
title: t('input.editor.orderedList'),
description: t('input.editor.orderedListTooltip'),
icon: 'fa-list-ol',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleOrderedList()
.run()
},
},
{
title: t('input.editor.taskList'),
description: t('input.editor.taskListTooltip'),
icon: 'fa-list-check',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleTaskList()
.run()
},
},
{
title: t('input.editor.quote'),
description: t('input.editor.quoteTooltip'),
icon: 'fa-quote-right',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleBlockquote()
.run()
},
},
{
title: t('input.editor.code'),
description: t('input.editor.codeTooltip'),
icon: 'fa-code',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleCodeBlock()
.run()
},
},
{
title: t('input.editor.image'),
description: t('input.editor.imageTooltip'),
icon: 'fa-image',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.run()
document.getElementById('tiptap__image-upload').click()
},
},
{
title: t('input.editor.horizontalRule'),
description: t('input.editor.horizontalRuleTooltip'),
icon: 'fa-ruler-horizontal',
command: ({editor, range}) => {
editor
.chain()
.focus()
.deleteRange(range)
.setHorizontalRule()
.run()
},
},
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()))
},
render: () => {
let component: VueRenderer
let popup
return {
onStart: props => {
component = new VueRenderer(CommandsList, {
// using vue 2:
// parent: this,
// propsData: props,
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
}
}

View File

@ -1,6 +0,0 @@
export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
export interface BottomAction {
title: string
action: () => void,
}

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