Compare commits

..

31 Commits

Author SHA1 Message Date
kolaente c058835874
feat(filters): move filter query to contenteditable 2024-03-05 17:47:06 +01:00
kolaente 11bc4764de
feat(filters): add basic autocomplete component 2024-03-05 10:37:26 +01:00
kolaente 9f7f187440
chore: update lockfile 2024-03-05 09:58:36 +01:00
kolaente e843438efd
feat(filters): show user name and avatar for assignee filters 2024-03-05 09:58:10 +01:00
kolaente 9381f65ceb
fix(filters): date filter value not populated 2024-03-05 09:58:10 +01:00
kolaente 712f8fc13b
feat(filters): add date values 2024-03-05 09:58:09 +01:00
kolaente f699b53744
feat(filters): make date values in filter query editable 2024-03-05 09:58:09 +01:00
kolaente 74a39a5cf0
chore(filters): copy datepicker 2024-03-05 09:58:09 +01:00
kolaente f137064ea9
chore(filters): add histoire story file 2024-03-05 09:58:09 +01:00
kolaente caf3cb216d
feat(filters): parse date properties to enable datepicker button 2024-03-05 09:58:09 +01:00
kolaente 4f15f27fe1
fix(filters): use readable colors for dark and light mode 2024-03-05 09:58:09 +01:00
kolaente d75c20ea48
feat(filter): add auto resize for filter query input 2024-03-05 09:58:09 +01:00
kolaente 0359b12648
feat(filter): add basic highlighting filter query component 2024-03-05 09:58:09 +01:00
kolaente bcd414b5e7
feat(filters): make new filter syntax work with Typesense 2024-03-05 09:58:04 +01:00
kolaente 7c47930f8e
fix(filters): lint 2024-03-05 09:58:00 +01:00
kolaente fc7c873dd6
chore(filters): cleanup old variables 2024-03-05 09:58:00 +01:00
kolaente 8d34f9b260
fix(tests): make filter tests work again 2024-03-05 09:57:59 +01:00
kolaente ffcfc85b00
fix(filter): correctly filter for buckets 2024-03-05 09:57:59 +01:00
kolaente 52c8ed9738
feat(filter): add in keyword 2024-03-05 09:57:59 +01:00
kolaente cc78411866
feat(filter): add better error message when passing an invalid filter expression 2024-03-05 09:57:59 +01:00
kolaente b5e781fedb
chore(filter): cleanup 2024-03-05 09:57:59 +01:00
kolaente 5fe9fc73a9
feat(filter): migrate existing saved filters 2024-03-05 09:57:59 +01:00
kolaente d30615d527
feat(filter): nesting 2024-03-05 09:57:59 +01:00
kolaente 605a2131ba
feat(filter): more tests 2024-03-05 09:57:59 +01:00
kolaente 9cd88e97e4
fix(filter): translate all tests 2024-03-05 09:57:59 +01:00
kolaente afb425f0c2
fix(filter): allow filtering for "project" 2024-03-05 09:57:59 +01:00
kolaente 16f206b3cc
fix(filter): allow filtering on "in" condition 2024-03-05 09:57:59 +01:00
kolaente d9cb2d1755
fix(filter): don't crash on empty filter 2024-03-05 09:57:59 +01:00
kolaente 54a9ea84d5
fix(filter): make sure single filter condition works 2024-03-05 09:57:59 +01:00
kolaente f470c0c297
feat(filters): basic text filter works now 2024-03-05 09:57:58 +01:00
kolaente 8d2f6c8567
feat(filters): very basic filter parsing 2024-03-05 09:57:58 +01:00
298 changed files with 16237 additions and 29207 deletions

View File

@ -3,11 +3,6 @@ kind: pipeline
type: docker
name: build-and-test
trigger:
event:
exclude:
- cron
workspace:
base: /go
path: src/code.vikunja.io/api
@ -139,7 +134,7 @@ steps:
event: [ push, tag, pull_request ]
- name: api-lint
image: golangci/golangci-lint:v1.57.2
image: golangci/golangci-lint:v1.55.2
pull: always
environment:
GOPROXY: 'https://goproxy.kolaente.de'
@ -364,7 +359,7 @@ steps:
- api-build
- name: frontend-dependencies
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -378,7 +373,7 @@ steps:
# - restore-cache
- name: frontend-lint
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -390,7 +385,7 @@ steps:
- frontend-dependencies
- name: frontend-build-prod
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -402,7 +397,7 @@ steps:
- frontend-dependencies
- name: frontend-test-unit
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
commands:
- cd frontend
@ -413,7 +408,7 @@ steps:
- name: frontend-typecheck
failure: ignore
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -461,6 +456,7 @@ steps:
- cp -r dist-test dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
@ -532,9 +528,6 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
# Needed to get the versions right as they depend on tags
@ -544,7 +537,7 @@ steps:
- git fetch --tags
- name: frontend-dependencies
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -556,7 +549,7 @@ steps:
- pnpm install --fetch-timeout 100000
- name: frontend-build
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -716,7 +709,7 @@ steps:
# Build os packages and push it to our bucket
- name: build-os-packages-unstable
image: goreleaser/nfpm:v2.36.1
image: goreleaser/nfpm:v2.35.3
pull: always
commands:
- apk add git go
@ -732,7 +725,7 @@ steps:
depends_on: [ after-build-compress ]
- name: build-os-packages-version
image: goreleaser/nfpm:v2.36.1
image: goreleaser/nfpm:v2.35.3
pull: always
commands:
- apk add git go
@ -815,9 +808,6 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: fetch-tags
@ -901,7 +891,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -962,7 +952,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.12.2-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -1155,9 +1145,6 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: fetch-tags
@ -1373,9 +1360,6 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
depends_on:
- build-and-test
@ -1400,6 +1384,6 @@ steps:
- failure
---
kind: signature
hmac: f2753482faf9e2a3d34a9111587a75dfb4519cb77002cc64a51266540fd2478e
hmac: 008b86263a8d03806da907c128a837a380901f1a2190a658c22d4e06cadc1b64
...

1
.gitignore vendored
View File

@ -28,4 +28,3 @@ vendor/
os-packages/
mage_output_file.go
mage-static
.direnv/

View File

@ -18,7 +18,6 @@ linters:
- scopelint # Obsolete, using exportloopref instead
- durationcheck
- goconst
- musttag
presets:
- bugs
- unused
@ -100,11 +99,3 @@ issues:
- path: pkg/modules/migration/ticktick/ticktick_test.go
linters:
- testifylint
- path: pkg/migration/*
text: "parameter 'tx' seems to be unused, consider removing or renaming it as"
linters:
- revive
- path: pkg/models/typesense.go
text: 'structtag: struct field Position repeats json tag "position" also at'
linters:
- govet

View File

@ -5,7 +5,7 @@
"eslint.packageManager": "pnpm",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
"source.fixAll": true
},
"eslint.format.enable": true,
"[javascript]": {

View File

@ -1,11 +1,10 @@
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:20.12.2-alpine AS frontendbuilder
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS frontendbuilder
WORKDIR /build
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true
ENV CYPRESS_INSTALL_BINARY 0
COPY frontend/ ./

View File

@ -29,7 +29,7 @@ If you find any security-related issues you don't want to disclose publicly, ple
## Features
See [the features page](https://vikunja.io/features/) on our website for a more exhaustive list or
See [the features page](https://vikunja.io/features/) on our website for a more exaustive list or
try it on [try.vikunja.io](https://try.vikunja.io)!
## Docs

View File

@ -62,9 +62,6 @@ service:
allowiconchanges: true
# Allow using a custom logo via external URL.
customlogourl: ''
# Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them
# discoverable when sharing a project, therefore not only showing teams the user is member of.
enablepublicteams: false
sentry:
# If set to true, enables anonymous error tracking of api errors via Sentry. This allows us to gather more
@ -156,7 +153,7 @@ mailer:
username: "user"
# SMTP password
password: ""
# Whether to skip verification of the tls certificate on the server
# Wether to skip verification of the tls certificate on the server
skiptlsverify: false
# The default from address when sending emails
fromemail: "mail@vikunja"
@ -306,7 +303,7 @@ auth:
# The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
# **Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
# If the email is not public in those cases, authenticating will fail.
# +**Note 2:** The frontend expects the third party to redirect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
# **Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
# The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
# If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
# Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
@ -368,8 +365,8 @@ defaultsettings:
webhooks:
# Whether to enable support for webhooks
enabled: true
# The timeout in seconds until a webhook request fails when no response has been received.
timeoutseconds: 30
# The timout in seconds until a webhook request fails when no response has been received.
timoutseconds: 30
# The URL of [a mole instance](https://github.com/frain-dev/mole) to use to proxy outgoing webhook requests. You should use this and configure appropriately if you're not the only one using your Vikunja instance. More info about why: https://webhooks.fyi/best-practices/webhook-providers#implement-security-on-egress-communication. Must be used in combination with `webhooks.password` (see below).
proxyurl:
# The proxy password to use when authenticating against the proxy.

9
desktop/default.nix Normal file
View File

@ -0,0 +1,9 @@
{ pkgs ? import <nixpkgs> {}
}:
pkgs.mkShell {
name="electron-dev";
buildInputs = [
pkgs.electron
];
}

View File

@ -51,11 +51,11 @@
}
},
"devDependencies": {
"electron": "29.3.3",
"electron": "29.1.0",
"electron-builder": "24.13.3"
},
"dependencies": {
"connect-history-api-fallback": "2.0.0",
"express": "4.19.2"
"express": "4.18.3"
}
}

File diff suppressed because it is too large Load Diff

2028
desktop/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ In other packages, use the `db.NewSession()` method to get a new database sessio
To add a new table to the database, create the struct and [add a migration for it]({{< ref "db-migrations.md" >}}).
To learn more about how to configure your struct to create "good" tables, refer to [the xorm documentation](https://xorm.io/docs/).
To learn more about how to configure your struct to create "good" tables, refer to [the xorm documentaion](https://xorm.io/docs/).
In most cases you will also need to implement the `TableName() string` method on the new struct to make sure the table name matches the rest of the tables - plural.

View File

@ -26,15 +26,6 @@ If you plan to do a bigger change, it is better to open an issue for discussion
The main repo is [`vikunja/vikunja`](https://kolaente.dev/vikunja/vikunja), it contains all code for the api, frontend and desktop applications.
## Where to file issues
You can file issues on [the Gitea repo](https://kolaente.dev/vikunja/vikunja) or [on the GitHub mirror](https://github.com/go-vikunja/vikunja), when you don't want to create an account on the Gitea instance.
Please note that due to a spam problem, we need to manually enable accounts on Gitea after you've registered there.
To get that started, please reach out on another channel with your username.
Another option is [the community forum](https://community.vikunja.io), especially if you want to discuss a feature in more detail.
## API
You'll need at least Go 1.21 to build Vikunja's api.

View File

@ -101,7 +101,7 @@ You should also document the routes with [swagger annotations]({{< ref "swagger-
There is a method available in the `migration` package which takes a fully nested Vikunja structure and creates it with all relations.
This means you start by adding a project, then add projects inside that project, then tasks in the lists and so on.
In general, it is recommended to have one root project with all projects of the other service as child projects.
In general, it is reccommended to have one root project with all projects of the other service as child projects.
The root structure must be present as `[]*models.ProjectWithTasksAndBuckets`. It allows to represent all of Vikunja's hierarchy as a single data structure.

View File

@ -16,7 +16,7 @@ menu:
The following parts are about the kinds of tests in the API package and how to run them.
### Prerequisites
### Prerequesites
To run any kind of test, you need to specify Vikunja's [root path](https://vikunja.io/docs/config-options/#rootpath).
This is required to make sure all test fixtures are correctly loaded.
@ -39,7 +39,7 @@ This definition is a bit blurry, but we haven't found a better one yet.
All integration tests live in `pkg/integrations`.
You can run them by executing `mage test:integration`.
The integration tests use the same config and fixtures as the unit tests and therefore have the same options available,
The integration tests use the same config and fixtures as the unit tests and therefor have the same options available,
see at the beginning of this document.
To run integration tests, use `mage test:integration`.

View File

@ -18,7 +18,6 @@ To fully build Vikunja from source files, you need to build the api and frontend
1. Make sure you have git installed
2. Clone the repo with `git clone https://code.vikunja.io/vikunja` and switch into the directory.
3. Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use. If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
## Frontend
@ -36,7 +35,7 @@ That means compiling it boils down to these steps:
1. Make sure [Go](https://golang.org/doc/install) is properly installed on your system. You'll need at least Go `1.21`.
2. Make sure [Mage](https://magefile.org) is properly installed on your system.
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch frontend/dist/index.html`.
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch index.html`.
4. Run `mage build` in the source of the main repo. This will build a binary in the root of the repo which will be able to run on your system.
### Build for different architectures

View File

@ -346,18 +346,6 @@ Full path: `service.customlogourl`
Environment path: `VIKUNJA_SERVICE_CUSTOMLOGOURL`
### enablepublicteams
Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them
discoverable when sharing a project, therefore not only showing teams the user is member of.
Default: `false`
Full path: `service.enablepublicteams`
Environment path: `VIKUNJA_SERVICE_ENABLEPUBLICTEAMS`
---
## sentry
@ -780,7 +768,7 @@ Environment path: `VIKUNJA_MAILER_PASSWORD`
### skiptlsverify
Whether to skip verification of the tls certificate on the server
Wether to skip verification of the tls certificate on the server
Default: `false`
@ -1223,7 +1211,7 @@ OpenID configuration will allow users to authenticate through a third-party Open
The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
**Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
If the email is not public in those cases, authenticating will fail.
+**Note 2:** The frontend expects the third party to redirect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
**Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
@ -1422,15 +1410,15 @@ Full path: `webhooks.enabled`
Environment path: `VIKUNJA_WEBHOOKS_ENABLED`
### timeoutseconds
### timoutseconds
The timeout in seconds until a webhook request fails when no response has been received.
The timout in seconds until a webhook request fails when no response has been received.
Default: `30`
Full path: `webhooks.timeoutseconds`
Full path: `webhooks.timoutseconds`
Environment path: `VIKUNJA_WEBHOOKS_TIMEOUTSECONDS`
Environment path: `VIKUNJA_WEBHOOKS_TIMOUTSECONDS`
### proxyurl

View File

@ -40,7 +40,7 @@ chown 1000 $PWD/files
You'll need to do this before running any of the examples on this page.
Vikunja will not try to acquire ownership of the files folder, as that would mean it had to run as root.
Vikunja will not try to aquire ownership of the files folder, as that would mean it had to run as root.
## PostgreSQL
@ -312,7 +312,7 @@ To do that, you can
* Either activate SSH and paste the adapted compose file in a terminal (using Putty or similar)
* Without activating SSH as a "custom script" (go to Control Panel / Task Scheduler / Create / Scheduled Task / User-defined script)
* Without activating SSH, by using Portainer (you have to install first, check out [this tutorial](https://www.portainer.io/blog/how-to-install-portainer-on-a-synology-nas) for example):
* Without activating SSH, by using Portainer (you have to install first, check out [this tutorial](https://www.portainer.io/blog/how-to-install-portainer-on-a-synology-nas) for exmple):
1. Go to **Dashboard / Stacks** click the button **"Add Stack"**
2. Give it the name Vikunja and paste the adapted docker compose file
3. Deploy the Stack with the "Deploy Stack" button:

View File

@ -37,7 +37,7 @@ This document provides an overview and instructions for the different methods:
* [FreeBSD](#freebsd--freenas)
* [Kubernetes]({{< ref "k8s.md" >}})
And after you installed Vikunja, you may want to check out these other resources:
And after you installed Vikunja, you may want to check out these other ressources:
* [Configuration]({{< ref "config.md">}})
* [UTF-8 Settings]({{< ref "utf-8.md">}})
@ -225,13 +225,10 @@ go install github.com/magefile/mage
```
mkdir /mnt/GO/code.vikunja.io
cd /mnt/GO/code.vikunja.io
git clone https://code.vikunja.io/vikunja
cd vikunja
git clone https://code.vikunja.io/api
cd /mnt/GO/code.vikunja.io/api
```
**Note:** Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use.
If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
### Compile binaries
```

View File

@ -1,57 +0,0 @@
---
date: "2023-03-09:00:00+02:00"
title: "Migration from third-party services"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
weight: 5
---
# Migration from third-party services
There are several importers available for third-party services like Trello, Microsoft To Do or Todoist.
All available migration options can be found [here](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample#L218).
You can develop migrations for more services, see the [documentation]({{< ref "../development/migration.md">}}) for more info.
{{< table_of_contents >}}
## Trello
### Config Setup
Log into Trello and navigate to the [site](https://trello.com/app-key) to manage your API keys.
Save your `Personal Key` for later and add your Vikunja domain to the Allowed Origins list.
Create a `config.yml` file based on [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) if you haven't already.
- Copy the [Trello options](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample#L233) from the default config file
- Set `enable` to true
- Set `key` to your [trello API key](https://trello.com/app-key)
- Replace `<frontend url>` in `redirecturl` with your url
### Config Loading
To load the config with Vikunja, see the [installation]({{< ref "install.md">}}) documentation for instructions to load the `config.yml` file and start Vikunja.
### Config Loading with Docker Compose
In case you are using Docker Compose you need to edit `docker-compose.yml` to load `config.yml`.
Mount the `config.yml` file into the Vikunja container, by adding this line to the volumes of the Vikunja container and replacing the `./path/to/config.yml` with the relative path from the `docker-compose.yml` to your `config.yml`.
```yaml
volumes:
- ./path/to/config.yml:/etc/vikunja/config.yml
```
After all the setup is done, start Vikunja as shown in the [Docker Compose setup]({{< ref "full-docker-example.md">}}).
### Start the Migration Process
Log in, and navigate to Settings > Import from other services. In the list of available third-party services, there should be a Trello icon now.
If not, ensure that you are properly loading your config file. Refer to the Vikunja log to see if the config file was loaded or not.
In case the config file was loaded, and there is no Trello icon, make sure your [config setup](#config-setup) is correct.
Click on Trello and on Get Started. This will redirect you to Trello where you need to allow Vikunja Migration to access your account. In case there is an error when being directed to Trello, make sure that your Vikunja domain is in your Trello Allowed Origins list.
Once this is done, you will be redirected to Vikunja which should tell you that the migration is in progress now. Note that this can take up to several hours depending on the amount of boards in your Trello account.

View File

@ -17,7 +17,7 @@ Vikunja allows for authentication with an external identity source such as Authe
## OpenID Connect Overview
OpenID Connect is a standardized identity layer built on top of the more generic OAuth 2.0 specification, simplifying interaction between the involved parties significantly.
OpenID Connect is a standardized identity layer built on top of the more generic OAuth 2.0 specification, simplying interaction between the involved parties significantly.
While the [OpenID specification](https://openid.net/specs/openid-connect-core-1_0.html#Overview) is worth a read, we summarize the most important basics here.
The involved parties are:
@ -34,22 +34,6 @@ Claims in turn are assertions containing information about the token bearer, usu
**Scopes** are requested by the client when redirecting the end-user to the Authorization Server for authentication, and indirectly control which claims are included in the resulting tokens.
There's certain default scopes, but its also possible to define custom scopes, which are used by the feature assigning users to Teams automatically.
## Supported and required claims
Vikunja only requires a few claims to be present in the ID token to successfully authenticate the user.
Additional claims can be added though to customize behaviour during user creation.
The following table gives an overview about the claims supported by Vikunja. The scope column lists the scope that should request the claim according to the [OpenID Connect Standard](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims). It omits the claims such as `sub` or `issuer` required by the `openid` scope, which must always be present.
| Claim | Type | Scope | Comment |
| ------|------|-------|---------|
| email | required | email | Sets the email address of the user. Taken from the `userinfo` endpoint if not present in ID token. User creation fails if claim not present and userinfo lookup fails. |
| name | optional | profile | Sets the display name of the user. Taken from the `userinfo` endpoint if not present in ID token. |
| preferred_username | optional | profile | Sets the username of the user. Taken from the `userinfo` endpoint if not present in ID token. If this also doesn't contain the claim, use the `nickname` claim from `userinfo` instead. If that one is not available either, the username is auto-generated by Vikunja. |
| vikunja_groups | optional | N/A | Can be used to automatically assign users to teams. See below for a more detailed explanation about the expected format and implementation examples. |
If one of the claims `email`, `name` or `preferred_username` is missing from the ID token, Vikunja will attempt to query the `userinfo` endpoint to obtain the information from there.
## Configuring OIDC Authentication
To achieve authentication via an external provider, it is required to (a) configure a confidential Client on your OAuth 2.0 provider and (b) configure Vikunja to authenticate against this provider.
@ -67,7 +51,7 @@ In general, this involves the following steps at a minimum:
- Make sure the required scopes (`openid profile email` are the default scopes used by Vikunja) are supported
- Optional: configure an additional scope for automatic team assignment, see below for details
More detailed instructions for various different identity providers can be [found here]({{< ref "openid-examples.md">}})
More detailled instructions for various different identity providers can be [found here]({{< ref "openid-examples.md">}})
### Step 2: Configure Vikunja
@ -80,17 +64,13 @@ auth:
redirecturl: https://vikunja.mydomain.com/auth/openid/ <---- slash at the end is important
providers:
- name: <provider-name>
authurl: <auth-url> <----- Used for OIDC Discovery, usually the issuer
authurl: <auth-url>
clientid: <vikunja client-id>
clientsecret: <vikunja client-secret>
scope: openid profile email
```
The value for `authurl` can be obtained from the metadata of your provider.
Note that the `authurl` is used for [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html).
Typically, you'll want to use the `issuer` URL as found in the provider metadata.
The values for `clientid` and `clientsecret` are typically obtained when configuring the client.
The values for `authurl` can be obtained from the Metadata of your provider, while `clientid` and `clientsecret` are obtained when configuring the client.
The scope usually doesn't need to be specified or changed, unless you want to configure the automatic team assignment.
Optionally it is possible to disable local authentication and therefore forcing users to login via OpenID connect:
@ -103,7 +83,7 @@ auth:
## Automatically assign users to teams
Starting with version 0.24.0, Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
If configured, Vikunja will sync teams, automatically create new ones and make sure the members are part of the configured teams.
Teams which exist only because they were created from oidc attributes are not editable in Vikunja.
@ -115,7 +95,7 @@ It depends on the provider being used as well as the preferences of the administ
Typically you'd want to request an additional scope (e.g. `vikunja_scope`) which then triggers the identity provider to add the claim.
If the `vikunja_groups` is part of the **ID token**, Vikunja will start the procedure and import teams and team memberships.
The minimal claim structure expected by Vikunja is as follows:
The claim structure expexted by Vikunja is as follows:
```json
{
@ -132,21 +112,6 @@ The minimal claim structure expected by Vikunja is as follows:
}
```
It is also possible to pass the `description` and the `isPublic` flag as optional parameters. If not present, the description will be empty and project visibility defaults to false.
```json
{
"vikunja_groups": [
{
"name": "team 3",
"oidcID": 33349,
"description": "My Team Description",
"isPublic": true
},
]
}
```
For each team, you need to define a team `name` and an `oidcID`, where the `oidcID` can be any string with a length of less than 250 characters.
The `oidcID` is used to uniquely identify the team, so please make sure to keep this unique.

View File

@ -17,7 +17,7 @@ However, you can still run it in a subdirectory but need to build the frontend y
First, make sure you're able to build the frontend from source.
Check [the guide about building from source]({{< ref "build-from-source.md">}}#frontend) about that.
### Dynamically set with build command
### Dynamicly set with build command
Run the build with the `VIKUNJA_FRONTEND_BASE` variable specified.

View File

@ -10,7 +10,7 @@ menu:
# Vikunja Versions
Vikunja api is available in two different release flavors.
The Vikunja api and frontend are available in two different release flavors.
{{< table_of_contents >}}
@ -30,9 +30,6 @@ There might be multiple new such builds a day.
Versions contain the last stable version, the number of commits since then and the commit the currently running binary was built from.
They look like this: `v0.18.1+269-5cc4927b9e`
Since a release is also cut from the main branch at some point, features from unstable will eventually become available in stable releases.
At the point in time of a new version release, the unstable build is the same exact thing.
The demo instance at [try.vikunja.io](https://try.vikunja.io) automatically updates and always runs the last unstable build.
## Switching between versions

View File

@ -72,7 +72,6 @@ Vikunja **currently does not** support these properties:
* [Evolution](https://wiki.gnome.org/Apps/Evolution/)
* [OpenTasks](https://opentasks.app/) & [DAVx⁵](https://www.davx5.com/)
* [Tasks (Android)](https://tasks.org/)
* [Korganizer](https://apps.kde.org/korganizer/)
### Not working

View File

@ -28,21 +28,13 @@ All commands use the same standard [config file]({{< ref "../setup/config.md">}}
## Using the cli in docker
When running Vikunja in docker, you'll need to execute all commands in the `vikunja` container.
When running Vikunja in docker, you'll need to execute all commands in the `api` container.
Instead of running the `vikunja` binary directly, run it like this:
```sh
docker exec <name of the vikunja container> /app/vikunja/vikunja <subcommand>
docker exec <name of the vikunja api container> /app/vikunja/vikunja <subcommand>
```
If you need to run a bunch of Vikunja commands, you can also create a shell alias for it:
```sh
alias vikunja-docker='docker exec <name of the vikunja container> /app/vikunja/vikunja'
```
Then use it as `vikunja-docker <subcommand>`.
### `dump`
Creates a zip file with all vikunja-related files.
@ -147,7 +139,7 @@ Flags:
#### `user delete`
Start the user deletion process.
If called without the `--now` flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process (this is the same behavior as if the user requested their deletion via the web interface).
If called without the `--now` flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process (this is the same behavoir as if the user requested their deletion via the web interface).
With the flag the user is deleted **immediately**.
**USE WITH CAUTION.**

View File

@ -51,24 +51,23 @@ This document describes the different errors Vikunja can return.
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 2001 | 400 | ID cannot be empty or 0. |
| 2002 | 400 | Some of the request data was invalid. The response contains an additional array with all invalid fields. |
| 2002 | 400 | Some of the request data was invalid. The response contains an aditional array with all invalid fields. |
## Project
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. |
| 3013 | 412 | This project cannot be archived because a user has set it as their default project. |
| 3014 | 404 | This project view does not exist. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. |
| 3013 | 412 | This project cannot be archived because a user has set it as their default project. |
## Task
@ -98,8 +97,6 @@ This document describes the different errors Vikunja can return.
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4024 | 400 | The provided filter expression is invalid. |
| 4025 | 400 | The reaction kind is invalid. |
| 4026 | 400 | You must provide a project view ID when sorting by position. |
## Team

View File

@ -1,67 +0,0 @@
---
title: "Filters"
date: 2024-03-09T19:51:32+02:00
draft: false
type: doc
menu:
sidebar:
parent: "usage"
---
# Filter Syntax
To filter tasks via the api, you can use a query syntax similar to SQL.
This document is about filtering via the api. To filter in Vikunja's web ui, check out the help text below the filter query input.
{{< table_of_contents >}}
## Available fields
The available fields for filtering include:
* `done`: Whether the task is completed or not
* `priority`: The priority level of the task (1-5)
* `percentDone`: The percentage of completion for the task (0-100)
* `dueDate`: The due date of the task
* `startDate`: The start date of the task
* `endDate`: The end date of the task
* `doneAt`: The date and time when the task was completed
* `assignees`: The assignees of the task
* `labels`: The labels associated with the task
* `project`: The project the task belongs to (only available for saved filters, not on a project level)
You can date math to set relative dates. Click on the date value in a query to find out more.
All strings must be either single-word or enclosed in `"` or `'`. This extends to date values like `2024-03-11`.
## Operators
The available operators for filtering include:
* `!=`: Not equal to
* `=`: Equal to
* `>`: Greater than
* `>=`: Greater than or equal to
* `<`: Less than
* `<=`: Less than or equal to
* `like`: Matches a pattern (using wildcard `%`)
* `in`: Matches any value in a comma-seperated list of values
To combine multiple conditions, you can use the following logical operators:
* `&&`: AND operator, matches if all conditions are true
* `||`: OR operator, matches if any of the conditions are true
* `(` and `)`: Parentheses for grouping conditions
## Examples
Here are some examples of filter queries:
* `priority = 4`: Matches tasks with priority level 4
* `dueDate < now`: Matches tasks with a due date in the past
* `done = false && priority >= 3`: Matches undone tasks with priority level 3 or higher
* `assignees in [user1, user2]`: Matches tasks assigned to either "user1" or "user2
* `(priority = 1 || priority = 2) && dueDate <= now`: Matches tasks with priority level 1 or 2 and a due date in the past

View File

@ -18,7 +18,7 @@ Starting with version 0.22.0, Vikunja allows you to define webhooks to notify ot
To create a webhook, in the project options select "Webhooks". The form will allow you to create and modify webhooks.
Check out [the api docs](https://try.vikunja.io/api/v1/docs#tag/webhooks) for information about how to create webhooks programmatically.
Check out [the api docs](https://try.vikunja.io/api/v1/docs#tag/webhooks) for information about how to create webhooks programatically.
## Available events and their payload

View File

@ -1,20 +0,0 @@
{
description = "Vikunja dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = with pkgs; [
# General tools
git-cliff
# Frontend tools
nodePackages.pnpm cypress
# API tools
go golangci-lint mage
# Desktop
electron
];
};
};
}

1
frontend/.gitignore vendored
View File

@ -13,6 +13,7 @@ node_modules
/dist*
coverage
*.zip
.direnv/
# Test files
cypress/screenshots

View File

@ -1 +1 @@
20.13.0
20.11.0

View File

@ -1,50 +1,15 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from "../../factories/project_view";
export function createDefaultViews(projectId) {
ProjectViewFactory.truncate()
const list = ProjectViewFactory.create(1, {
id: 1,
project_id: projectId,
view_kind: 0,
}, false)
const gantt = ProjectViewFactory.create(1, {
id: 2,
project_id: projectId,
view_kind: 1,
}, false)
const table = ProjectViewFactory.create(1, {
id: 3,
project_id: projectId,
view_kind: 2,
}, false)
const kanban = ProjectViewFactory.create(1, {
id: 4,
project_id: projectId,
view_kind: 3,
bucket_configuration_mode: 1,
}, false)
return [
list[0],
gantt[0],
table[0],
kanban[0],
]
}
export function createProjects() {
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
projects.views = createDefaultViews(projects[0].id)
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {
}) {
export function prepareProjects(setProjects = (...args: any[]) => {}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)

View File

@ -2,7 +2,6 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from '../../factories/project_view'
describe('Project History', () => {
createFakeUserAndLogin()
@ -12,31 +11,24 @@ describe('Project History', () => {
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(7)
ProjectViewFactory.truncate()
projects.forEach(p => ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false))
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}/${projects[0].id}`)
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}/${projects[1].id}`)
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}/${projects[2].id}`)
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}/${projects[3].id}`)
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}/${projects[4].id}`)
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[6].id}/${projects[6].id}`)
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadProject')
// cy.visit('/')
@ -54,6 +46,5 @@ describe('Project History', () => {
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
.should('contain', projects[6].title)
})
})

View File

@ -11,7 +11,7 @@ describe('Project View Gantt', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/2')
cy.visit('/projects/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/2')
cy.visit('/projects/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/2')
cy.visit('/projects/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/2')
cy.visit('/projects/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/2')
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -83,7 +83,7 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/2')
cy.visit('/projects/1/gantt')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
@ -99,7 +99,7 @@ describe('Project View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 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/2')
cy.visit('/projects/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -4,64 +4,35 @@ import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from "../../factories/project_view";
import {TaskBucketFactory} from "../../factories/task_buckets";
function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = ProjectFactory.create(1)
const views = ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = BucketFactory.create(2, {
project_view_id: views[0].id,
project_id: projects[0].id,
})
const tasks = TaskFactory.create(count, {
project_id: projects[0].id,
bucket_id: buckets[0].id,
...attrs,
})
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
task: tasks[0],
view: views[0],
project: projects[0],
}
}
function createTaskWithBuckets(buckets, count = 1) {
const data = TaskFactory.create(10, {
project_id: 1,
})
TaskBucketFactory.truncate()
data.forEach(t => TaskBucketFactory.create(count, {
task_id: t.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false))
return data
return tasks[0]
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2, {
project_view_id: 4,
})
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = createTaskWithBuckets(buckets, 10)
cy.visit('/projects/1/4')
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -75,8 +46,11 @@ describe('Project View Kanban', () => {
})
it('Can add a new task to a bucket', () => {
createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
@ -94,7 +68,7 @@ describe('Project View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/4')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -108,7 +82,7 @@ describe('Project View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/4')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -129,7 +103,7 @@ describe('Project View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/4')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
@ -140,7 +114,7 @@ describe('Project View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/4')
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -163,14 +137,17 @@ describe('Project View Kanban', () => {
})
it('Can drag tasks around', () => {
const tasks = createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
const tasks = TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
@ -178,8 +155,12 @@ describe('Project View Kanban', () => {
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = createTaskWithBuckets(buckets, 5)
cy.visit('/projects/1/4')
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -187,33 +168,28 @@ describe('Project View Kanban', () => {
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
.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)
const views = ProjectViewFactory.create(2, {
BucketFactory.create(2, {
project_id: '{increment}',
view_kind: 3,
bucket_configuration_mode: 1,
})
BucketFactory.create(2)
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
TaskBucketFactory.create(5, {
project_view_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/'+views[0].id)
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', {timeout: 3000})
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
@ -225,23 +201,27 @@ describe('Project View Kanban', () => {
.first()
.click()
cy.get('.global-notification', {timeout: 1000})
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
cy.visit('/projects/1/4')
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const {task, view} = createSingleTaskInBucket(5)
cy.visit(`/projects/1/${view.id}`)
const task = createSingleTaskInBucket(5)
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
@ -259,18 +239,18 @@ describe('Project View Kanban', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.kanban .bucket .tasks')
.should('not.contain', task.title)
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
description: 'Lorem Ipsum',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.visit(`/projects/${task.project_id}/kanban`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -278,12 +258,12 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
description: '',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.visit(`/projects/${task.project_id}/kanban`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -291,15 +271,15 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
description: '<p></p>',
})
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.visit(`/projects/${task.project_id}/kanban`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('not.exist')
})
})
})

View File

@ -5,16 +5,15 @@ import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
describe('Project View List', () => {
describe('Project View Project', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/1')
.should('contain', '/projects/1/list')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
@ -25,10 +24,6 @@ describe('Project View List', () => {
})
it('Should create a new task', () => {
BucketFactory.create(2, {
project_view_id: 4,
})
const newTaskTitle = 'New task'
cy.visit('/projects/1')
@ -43,7 +38,7 @@ describe('Project View List', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -93,10 +88,10 @@ describe('Project View List', () => {
title: i => `task${i}`,
project_id: 1,
})
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.tasks')
.should('contain', tasks[20].title)
.should('contain', tasks[1].title)
cy.get('.tasks')
.should('not.contain', tasks[99].title)
@ -109,6 +104,6 @@ describe('Project View List', () => {
cy.get('.tasks')
.should('contain', tasks[99].title)
cy.get('.tasks')
.should('not.contain', tasks[20].title)
.should('not.contain', tasks[1].title)
})
})

View File

@ -1,15 +1,13 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
describe('Project View Table', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/3')
cy.visit('/projects/1/table')
cy.get('.project-table table.table')
.should('exist')
@ -19,9 +17,9 @@ describe('Project View Table', () => {
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/3')
cy.visit('/projects/1/table')
cy.get('.project-table .filter-container .button')
cy.get('.project-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
@ -44,7 +42,7 @@ describe('Project View Table', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/3')
cy.visit('/projects/1/table')
cy.get('.project-table table.table')
.contains(tasks[0].title)

View File

@ -33,14 +33,14 @@ describe('Projects', () => {
})
it('Should redirect to a specific project view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/views/*/tasks**').as('loadBuckets')
cy.visit('/projects/1/4')
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
cy.visit('/projects/1/kanban')
cy.url()
.should('contain', '/projects/1/4')
.should('contain', '/projects/1/kanban')
cy.wait('@loadBuckets')
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/4')
.should('contain', '/projects/1/kanban')
})
it('Should rename the project in all places', () => {

View File

@ -1,9 +1,9 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {createProjects} from '../project/prepareProjects'
function prepareLinkShare() {
const projects = createProjects()
const projects = ProjectFactory.create(1)
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
})
@ -32,13 +32,13 @@ describe('Link shares', () => {
cy.get('.tasks')
.should('contain', tasks[0].title)
cy.url().should('contain', `/projects/${project.id}/1#share-auth-token=${share.hash}`)
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}/1#share-auth-token=${share.hash}`)
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', project.title)

View File

@ -5,13 +5,11 @@ import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import {createDefaultViews} from "../project/prepareProjects";
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
const views = createDefaultViews(project.id)
BucketFactory.create(1, {
project_view_id: views[3].id,
project_id: project.id,
})
const tasks = []
let dueDate = startDueDate
@ -62,7 +60,7 @@ describe('Home Page Task Overview', () => {
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks(49)
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
@ -73,8 +71,9 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
@ -89,7 +88,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')

View File

@ -12,8 +12,6 @@ import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
import {createDefaultViews} from "../project/prepareProjects";
import { TaskBucketFactory } from '../../factories/task_buckets'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
@ -49,22 +47,21 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
createFakeUserAndLogin()
let projects: {}[]
let projects
let buckets
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
const views = createDefaultViews(projects[0].id)
buckets = BucketFactory.create(1, {
project_view_id: views[3].id,
project_id: projects[0].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -78,7 +75,7 @@ describe('Task', () => {
it('Inserts new tasks at the top of the project', () => {
TaskFactory.create(1)
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.project-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
@ -96,7 +93,7 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.tasks .task .fancycheckbox')
.first()
.click()
@ -107,7 +104,7 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.get('.tasks .task .favorite')
.first()
.click()
@ -116,12 +113,12 @@ describe('Task', () => {
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: 'Lorem Ipsum',
})
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -129,12 +126,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '',
})
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -142,12 +139,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '<p></p>',
})
cy.visit('/projects/1/1')
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -317,9 +314,8 @@ describe('Task', () => {
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
const views = createDefaultViews(projects[0].id)
BucketFactory.create(2, {
project_view_id: views[3].id,
project_id: '{increment}',
})
const tasks = TaskFactory.create(1, {
id: 1,
@ -468,15 +464,12 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
})
cy.visit(`/projects/${projects[0].id}/4`)
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -838,11 +831,12 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/4`)
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

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

View File

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

View File

@ -14,6 +14,7 @@ export class TaskFactory extends Factory {
project_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',
created: now.toISOString(),
updated: now.toISOString()
}

View File

@ -1,13 +0,0 @@
import {Factory} from '../support/factory'
export class TaskBucketFactory extends Factory {
static table = 'task_buckets'
static factory() {
return {
task_id: '{increment}',
bucket_id: '{increment}',
project_view_id: '{increment}',
}
}
}

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1712449641,
"narHash": "sha256-U9DDWMexN6o5Td2DznEgguh8TRIUnIl9levmit43GcI=",
"lastModified": 1701336116,
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "600b15aea1b36eeb43833a50b0e96579147099ff",
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee",
"type": "github"
},
"original": {

10
frontend/flake.nix Normal file
View File

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

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@9.1.0",
"packageManager": "pnpm@8.15.4",
"keywords": [
"todo",
"productivity",
@ -50,60 +50,60 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/fontawesome-svg-core": "6.5.1",
"@fortawesome/free-regular-svg-icons": "6.5.1",
"@fortawesome/free-solid-svg-icons": "6.5.1",
"@fortawesome/vue-fontawesome": "3.0.6",
"@github/hotkey": "3.1.0",
"@infectoone/vue-ganttastic": "2.3.2",
"@intlify/unplugin-vue-i18n": "4.0.0",
"@kyvg/vue3-notification": "3.2.1",
"@sentry/tracing": "7.114.0",
"@sentry/vue": "7.114.0",
"@tiptap/core": "2.3.2",
"@tiptap/extension-blockquote": "2.3.2",
"@tiptap/extension-bold": "2.3.2",
"@tiptap/extension-bullet-list": "2.3.2",
"@tiptap/extension-code": "2.3.2",
"@tiptap/extension-code-block-lowlight": "2.3.2",
"@tiptap/extension-document": "2.3.2",
"@tiptap/extension-dropcursor": "2.3.2",
"@tiptap/extension-gapcursor": "2.3.2",
"@tiptap/extension-hard-break": "2.3.2",
"@tiptap/extension-heading": "2.3.2",
"@tiptap/extension-history": "2.3.2",
"@tiptap/extension-horizontal-rule": "2.3.2",
"@tiptap/extension-image": "2.3.2",
"@tiptap/extension-italic": "2.3.2",
"@tiptap/extension-link": "2.3.2",
"@tiptap/extension-list-item": "2.3.2",
"@tiptap/extension-ordered-list": "2.3.2",
"@tiptap/extension-paragraph": "2.3.2",
"@tiptap/extension-placeholder": "2.3.2",
"@tiptap/extension-strike": "2.3.2",
"@tiptap/extension-table": "2.3.2",
"@tiptap/extension-table-cell": "2.3.2",
"@tiptap/extension-table-header": "2.3.2",
"@tiptap/extension-table-row": "2.3.2",
"@tiptap/extension-task-item": "2.3.2",
"@tiptap/extension-task-list": "2.3.2",
"@tiptap/extension-text": "2.3.2",
"@tiptap/extension-typography": "2.3.2",
"@tiptap/extension-underline": "2.3.2",
"@tiptap/pm": "2.3.2",
"@tiptap/suggestion": "2.3.2",
"@tiptap/vue-3": "2.3.2",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "2.0.0",
"@kyvg/vue3-notification": "3.2.0",
"@sentry/tracing": "7.105.0",
"@sentry/vue": "7.105.0",
"@tiptap/core": "2.2.4",
"@tiptap/extension-blockquote": "2.2.4",
"@tiptap/extension-bold": "2.2.4",
"@tiptap/extension-bullet-list": "2.2.4",
"@tiptap/extension-code": "2.2.4",
"@tiptap/extension-code-block-lowlight": "2.2.4",
"@tiptap/extension-document": "2.2.4",
"@tiptap/extension-dropcursor": "2.2.4",
"@tiptap/extension-gapcursor": "2.2.4",
"@tiptap/extension-hard-break": "2.2.4",
"@tiptap/extension-heading": "2.2.4",
"@tiptap/extension-history": "2.2.4",
"@tiptap/extension-horizontal-rule": "2.2.4",
"@tiptap/extension-image": "2.2.4",
"@tiptap/extension-italic": "2.2.4",
"@tiptap/extension-link": "2.2.4",
"@tiptap/extension-list-item": "2.2.4",
"@tiptap/extension-ordered-list": "2.2.4",
"@tiptap/extension-paragraph": "2.2.4",
"@tiptap/extension-placeholder": "2.2.4",
"@tiptap/extension-strike": "2.2.4",
"@tiptap/extension-table": "2.2.4",
"@tiptap/extension-table-cell": "2.2.4",
"@tiptap/extension-table-header": "2.2.4",
"@tiptap/extension-table-row": "2.2.4",
"@tiptap/extension-task-item": "2.2.4",
"@tiptap/extension-task-list": "2.2.4",
"@tiptap/extension-text": "2.2.4",
"@tiptap/extension-typography": "2.2.4",
"@tiptap/extension-underline": "2.2.4",
"@tiptap/pm": "2.2.4",
"@tiptap/suggestion": "2.2.4",
"@tiptap/vue-3": "2.2.4",
"@types/is-touch-device": "1.0.2",
"@types/lodash.clonedeep": "4.5.9",
"@vueuse/core": "10.9.0",
"@vueuse/router": "10.9.0",
"axios": "1.6.8",
"axios": "1.6.7",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"date-fns": "3.6.0",
"dayjs": "1.11.11",
"dompurify": "3.1.2",
"date-fns": "3.3.1",
"dayjs": "1.11.10",
"dompurify": "3.0.9",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@ -117,14 +117,13 @@
"snake-case": "3.0.4",
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.5.3",
"vue": "3.4.27",
"ufo": "1.4.0",
"vue": "3.4.21",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.13.1",
"vue-router": "4.3.2",
"vuemoji-picker": "0.2.1",
"workbox-precaching": "7.1.0",
"vue-i18n": "9.10.1",
"vue-router": "4.3.0",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
@ -132,61 +131,60 @@
"@cypress/vite-dev-server": "5.0.7",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.4.1",
"@histoire/plugin-screenshot": "0.17.17",
"@histoire/plugin-vue": "0.17.17",
"@rushstack/eslint-patch": "1.10.2",
"@tsconfig/node18": "18.2.4",
"@histoire/plugin-screenshot": "0.17.8",
"@histoire/plugin-vue": "0.17.12",
"@rushstack/eslint-patch": "1.7.2",
"@tsconfig/node18": "18.2.2",
"@types/codemirror": "5.60.15",
"@types/dompurify": "3.0.5",
"@types/flexsearch": "0.7.6",
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.12.11",
"@types/node": "20.11.24",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "7.8.0",
"@typescript-eslint/parser": "7.8.0",
"@vitejs/plugin-legacy": "5.4.0",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@vitejs/plugin-legacy": "5.3.1",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/test-utils": "2.4.6",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.4.4",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.19",
"autoprefixer": "10.4.18",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001617",
"css-has-pseudo": "6.0.3",
"caniuse-lite": "1.0.30001593",
"css-has-pseudo": "6.0.2",
"csstype": "3.1.3",
"cypress": "13.9.0",
"esbuild": "0.21.1",
"cypress": "13.6.6",
"esbuild": "0.20.1",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.26.0",
"happy-dom": "14.10.1",
"histoire": "0.17.17",
"postcss": "8.4.38",
"eslint-plugin-vue": "9.22.0",
"happy-dom": "13.6.2",
"histoire": "0.17.9",
"postcss": "8.4.35",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.5.11",
"rollup": "4.17.2",
"postcss-preset-env": "9.4.0",
"rollup": "4.12.0",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.77.0",
"sass": "1.71.1",
"start-server-and-test": "2.0.3",
"typescript": "5.4.5",
"vite": "5.2.11",
"typescript": "5.3.3",
"vite": "5.1.5",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.20.0",
"vite-plugin-pwa": "0.19.2",
"vite-plugin-sentry": "1.4.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.6.0",
"vue-tsc": "2.0.16",
"vitest": "1.3.1",
"vue-tsc": "2.0.4",
"wait-on": "7.2.0",
"workbox-cli": "7.1.0"
"workbox-cli": "7.0.0"
},
"pnpm": {
"patchedDependencies": {
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch",
"@github/hotkey@3.1.0": "patches/@github__hotkey@3.1.0.patch"
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
}
}
}

View File

@ -1,28 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index b6e6e0a6864cb00bc085b8d4503a705cb3bc8404..0466ef46406b0df41c8d0bb9a5bac9eabf4a50de 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -368,10 +368,12 @@ const sequenceTracker = new SequenceTracker({
function keyDownHandler(event) {
if (event.defaultPrevented)
return;
- if (!(event.target instanceof Node))
+ const target = event.explicitOriginalTarget || event.target;
+ if (target.shadowRoot)
return;
- if (isFormField(event.target)) {
- const target = event.target;
+ if (!(target instanceof Node))
+ return;
+ if (isFormField(target)) {
if (!target.id)
return;
if (!target.ownerDocument.querySelector(`[data-hotkey-scope="${target.id}"]`))
@@ -385,7 +387,6 @@ function keyDownHandler(event) {
sequenceTracker.registerKeypress(event);
currentTriePosition = newTriePosition;
if (newTriePosition instanceof Leaf) {
- const target = event.target;
let shouldFire = false;
let elementToFire;
const formField = isFormField(target);

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -47,7 +47,7 @@ import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
const importAccountDeleteService = () => import('@/services/accountDelete')
import {success} from '@/message'
const importMessage = () => import('@/message')
const baseStore = useBaseStore()
const authStore = useAuthStore()
@ -69,9 +69,11 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
return
}
const messageP = importMessage()
const AccountDeleteService = (await importAccountDeleteService()).default
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
const {success} = await messageP
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 313 KiB

View File

@ -91,7 +91,7 @@
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp />
<DatemathHelp/>
</modal>
</div>
</div>

View File

@ -68,7 +68,7 @@
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp />
<DatemathHelp/>
</modal>
</div>
</div>
@ -94,7 +94,6 @@ import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
required: false,
default: null,
},
open: {
type: Boolean,

View File

@ -26,6 +26,7 @@
:project="project"
:is-loading="projectUpdating[project.id]"
:can-collapse="canCollapse"
:level="level"
:data-project-id="project.id"
/>
</template>
@ -48,6 +49,7 @@ const props = defineProps<{
modelValue?: IProject[],
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits<{
(e: 'update:modelValue', projects: IProject[]): void

View File

@ -58,6 +58,7 @@
<ProjectSettingsDropdown
class="menu-list-dropdown"
:project="project"
:level="level"
>
<template #trigger="{toggleOpen}">
<BaseButton
@ -77,6 +78,7 @@
:model-value="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
@ -99,10 +101,12 @@ const {
project,
isLoading,
canCollapse,
level = 0,
} = defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>()
const projectStore = useProjectStore()

View File

@ -37,7 +37,7 @@
v-slot="{ Component }"
:route="routeWithModal"
>
<keep-alive :include="['project.view']">
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<component :is="Component" />
</keep-alive>
</router-view>
@ -162,7 +162,7 @@ projectStore.loadAllProjects()
.app-content {
z-index: 10;
position: relative;
padding: 1.5rem 0.5rem 0;
padding: 1.5rem 0.5rem 1rem;
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
transition: margin-left $transition-duration;
@ -172,7 +172,7 @@ projectStore.loadAllProjects()
}
@media screen and (min-width: $tablet) {
padding: $navbar-height + 1.5rem 1.5rem 0 1.5rem;
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
}
&.is-menu-enabled {

View File

@ -1,27 +1,26 @@
<template>
<div
:class="{
'has-background': background,
'link-share-is-fullwidth': isFullWidth,
}"
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>
<div class="has-text-centered link-share-view">
<Logo
v-if="logoVisible"
class="logo"
/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title"
>
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view />
<PoweredByLink />
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<Logo
v-if="logoVisible"
class="logo"
/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title"
>
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view />
<PoweredByLink />
</div>
</div>
</div>
</div>
@ -31,38 +30,14 @@
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useRoute} from 'vue-router'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
const projectStore = useProjectStore()
projectStore.loadAllProjects()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const route = useRoute()
const isFullWidth = computed(() => {
const viewId = route.params?.viewId ?? null
const projectId = route.params?.projectId ?? null
if (!viewId || !projectId) {
return false
}
const view = projectStore.projects[Number(projectId)]?.views.find(v => v.id === Number(viewId))
return view?.viewKind === PROJECT_VIEW_KINDS.KANBAN ||
view?.viewKind === PROJECT_VIEW_KINDS.GANTT
})
</script>
<style lang="scss" scoped>
@ -74,34 +49,20 @@ const isFullWidth = computed(() => {
.logo {
max-width: 300px;
width: 90%;
margin: 1rem auto 2rem;
margin: 2rem 0 1.5rem;
height: 100px;
}
.column {
max-width: 100%;
}
.title {
text-shadow: 0 0 1rem var(--white);
}
.link-share-view {
width: 100%;
max-width: $desktop;
margin: 0 auto;
}
.link-share-container.link-share-is-fullwidth {
.link-share-view {
max-width: 100vw;
}
}
.link-share-container:not(.has-background) {
:deep(.loader-container, .gantt-chart-container > .card) {
box-shadow: none !important;
border: none;
.task-add {
padding: 1rem 0 0;
}
}
// FIXME: this should be defined somewhere deep
.link-share-view .card {
background-color: var(--white);
}
</style>

View File

@ -155,8 +155,8 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
bottom: 0;
left: 0;
transform: translateX(-100%);
overflow-x: auto;
width: $navbar-width;
overflow-y: auto;
@media screen and (max-width: $tablet) {
top: 0;

View File

@ -0,0 +1,316 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
const TAB = 9,
ENTER = 13,
ESCAPE = 27,
ARROW_UP = 38,
ARROW_DOWN = 40
type state = 'unfocused' | 'focused'
const selectedIndex = ref(0)
const state = ref<state>('unfocused')
const val = ref<string>('')
const isResizing = ref(false)
const model = defineModel<string>()
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
const containerRef = ref<HTMLInputElement | null>(null)
const editorRef = ref<HTMLInputElement | null>(null)
watch(
() => model.value,
newValue => {
val.value = newValue
},
)
const placeholderText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (state.value === 'unfocused') {
return value ? '' : props.suggestion
}
if (!value || !value.trim()) {
return props.suggestion
}
return lookahead()
})
const spacerText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (!value || !value.trim()) {
return props.suggestion
}
return value
})
const props = withDefaults(defineProps<{
options: string[],
suggestion?: string,
maxHeight?: number,
}>(), {
maxHeight: 200,
})
function addSelectedIndex(offset: number) {
let nextIndex = Math.max(
0,
Math.min(selectedIndex.value + offset, props.options.length - 1),
)
if (!isFinite(nextIndex)) {
nextIndex = 0
}
selectedIndex.value = nextIndex
updateSuggestionScroll()
}
function highlight(words: string, query: string) {
return (words || '').replace(new RegExp(query, 'i'), '<mark class="scroll-term">' + query + '</mark>')
}
function lookahead() {
if (!props.options.length) {
return model.value
}
const index = Math.max(0, Math.min(selectedIndex.value, props.options.length - 1))
const match = props.options[index]
return model.value + (match ? match.substring(model.value?.length) : '')
}
function updateSuggestionScroll() {
nextTick(() => {
const scroller = suggestionScrollerRef.value
const selectedItem = scroller?.querySelector('.selected')
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
})
}
function updateScrollWindowSize() {
if (isResizing.value) {
return
}
isResizing.value = true
nextTick(() => {
isResizing.value = false
const scroller = suggestionScrollerRef.value
const parent = containerRef.value
if (scroller) {
const rect = parent.getBoundingClientRect()
const pxTop = rect.top
const pxBottom = window.innerHeight - rect.bottom
const maxHeight = Math.max(pxTop, pxBottom, props.maxHeight)
const isReversed = pxBottom < props.maxHeight && pxTop > pxBottom
scroller.style.maxHeight = Math.min(isReversed ? pxTop : pxBottom, props.maxHeight) + 'px'
scroller.parentNode.style.transform =
isReversed ? 'translateY(-100%) translateY(-1.4rem)'
: 'translateY(.4rem)'
}
})
}
function setState(stateName: state) {
state.value = stateName
if (stateName === 'unfocused') {
editorRef.value.blur()
} else {
updateScrollWindowSize()
}
}
function onFocusField(e) {
setState('focused')
}
function onKeydown(e) {
switch (e.keyCode || e.which) {
case ESCAPE:
e.preventDefault()
setState('unfocused')
break
case ARROW_UP:
e.preventDefault()
addSelectedIndex(-1)
break
case ARROW_DOWN:
e.preventDefault()
addSelectedIndex(1)
break
case ENTER:
case TAB:
e.preventDefault()
onSelectValue(lookahead() || model.value)
break
}
}
function onSelectValue(value) {
model.value = value
selectedIndex.value = 0
setState('unfocused')
}
function onUpdateField(e) {
setState('focused')
model.value = e.currentTarget.value
}
</script>
<template>
<div class="autocomplete" ref="containerRef">
<div class="entry-box">
<div class="spacer">{{ spacerText }}</div>
<div class="placeholder">{{ placeholderText }}</div>
<textarea class="field"
@input="onUpdateField"
@focus="onFocusField"
@keydown="onKeydown"
:class="state"
:value="val"
ref="editorRef"></textarea>
</div>
<div class="suggestion-list" v-if="state === 'focused' && options.length">
<div v-if="options && options.length" class="scroll-list">
<div class="items" ref="suggestionScrollerRef" @keydown="onKeydown">
<button
v-for="(item, index) in options"
class="item"
@click="onSelectValue(item)"
:class="{ selected: index === selectedIndex }"
:key="item"
v-html="highlight(item, val)"></button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.autocomplete {
position: relative;
* {
font-size: 1rem;
font-family: Consolas, Lucida Console, Courier New, monospace;
}
.entry-box {
position: relative;
width: 180px;
}
.spacer,
.placeholder,
.field {
border: none;
height: 100%;
padding: .1rem .2rem;
width: 100%;
}
.spacer {
min-height: 1rem;
visibility: hidden;
}
.placeholder {
user-select: none;
pointer-events: none;
opacity: 0.4;
z-index: 2;
}
.field {
z-index: 1;
&.focused {
color: blue;
}
}
.placeholder,
.field {
left: 0;
outline: none;
overflow: hidden;
position: absolute;
resize: none;
top: 0;
}
.suggestion-list {
position: absolute;
width: 100%;
}
.scroll-list {
position: absolute;
width: 100%;
border: solid 1px lightgray;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.items {
margin: 0;
max-height: 150px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background: #045068;
border-radius: 20px;
}
&::-webkit-scrollbar-track {
background: #dfe1e5;
}
}
.item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: solid 1px transparent;
background-color: white;
display: block;
width: 100%;
text-align: left;
&:hover {
cursor: pointer;
}
&:not(.selected):hover {
background-color: #c1dae2;
color: black;
}
&.selected {
background-color: #00aee6;
color: white;
}
}
.scroll-term {
font-weight: bold;
background-color: unset;
color: unset;
}
}
</style>

View File

@ -1,232 +0,0 @@
<script setup lang="ts">
import {type ComponentPublicInstance, nextTick, ref, watch} from 'vue'
const props = withDefaults(defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options: any[],
suggestion?: string,
maxHeight?: number,
}>(), {
maxHeight: 200,
suggestion: '',
})
const emit = defineEmits(['blur'])
const ESCAPE = 27,
ARROW_UP = 38,
ARROW_DOWN = 40
type StateType = 'unfocused' | 'focused'
const selectedIndex = ref(-1)
const state = ref<StateType>('unfocused')
const val = ref<string>('')
const model = defineModel<string>()
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
const containerRef = ref<HTMLInputElement | null>(null)
const editorRef = ref<HTMLInputElement | null>(null)
watch(
() => model.value,
newValue => {
val.value = newValue
},
)
function updateSuggestionScroll() {
nextTick(() => {
const scroller = suggestionScrollerRef.value
const selectedItem = scroller?.querySelector('.selected')
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
})
}
function setState(stateName: StateType) {
state.value = stateName
if (stateName === 'unfocused') {
emit('blur')
}
}
function onFocusField() {
setState('focused')
}
function onKeydown(e) {
switch (e.keyCode || e.which) {
case ESCAPE:
e.preventDefault()
setState('unfocused')
break
case ARROW_UP:
e.preventDefault()
select(-1)
break
case ARROW_DOWN:
e.preventDefault()
select(1)
break
}
}
const resultRefs = ref<(HTMLElement | null)[]>([])
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number) {
resultRefs.value[index] = el as (HTMLElement | null)
}
function select(offset: number) {
let index = selectedIndex.value + offset
if (!isFinite(index)) {
index = 0
}
if (index >= props.options.length) {
// At the last index, now moving back to the top
index = 0
}
if (index < 0) {
// Arrow up but we're already at the top
index = props.options.length - 1
}
const elems = resultRefs.value[index]
if (
typeof elems === 'undefined'
) {
return
}
selectedIndex.value = index
updateSuggestionScroll()
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems?.focus()
}
function onSelectValue(value) {
model.value = value
selectedIndex.value = 0
setState('unfocused')
}
function onUpdateField(e) {
setState('focused')
model.value = e.currentTarget.value
}
</script>
<template>
<div
ref="containerRef"
class="autocomplete"
>
<div class="entry-box">
<slot
name="input"
:on-update-field
:on-focus-field
:on-keydown
>
<textarea
ref="editorRef"
class="field"
:class="state"
:value="val"
@input="onUpdateField"
@focus="onFocusField"
@keydown="onKeydown"
/>
</slot>
</div>
<div
v-if="state === 'focused' && options.length"
class="suggestion-list"
>
<div
v-if="options && options.length"
class="scroll-list"
>
<div
ref="suggestionScrollerRef"
class="items"
@keydown="onKeydown"
>
<button
v-for="(item, index) in options"
:key="item"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, index)"
class="item"
:class="{ selected: index === selectedIndex }"
@click="onSelectValue(item)"
>
<slot
name="result"
:item
:selected="index === selectedIndex"
>
{{ item }}
</slot>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.autocomplete {
position: relative;
.suggestion-list {
position: absolute;
background: var(--white);
border-radius: 0 0 var(--input-radius) var(--input-radius);
border: 1px solid var(--primary);
border-top: none;
max-height: 50vh;
overflow-x: auto;
z-index: 100;
max-width: 100%;
min-width: 100%;
margin-top: -2px;
button {
width: 100%;
background: transparent;
border: 0;
font-size: .9rem;
width: 100%;
color: var(--grey-800);
text-align: left;
box-shadow: none;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem .75rem;
border: none;
cursor: pointer;
&:focus,
&:hover {
background: var(--grey-100);
box-shadow: none !important;
}
&:active {
background: var(--grey-100);
}
}
}
}
</style>

View File

@ -1,197 +0,0 @@
<script setup lang="ts">
import type {IReactionPerEntity, ReactionKind} from '@/modelTypes/IReaction'
import {VuemojiPicker} from 'vuemoji-picker'
import ReactionService from '@/services/reactions'
import ReactionModel from '@/models/reaction'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
import {useI18n} from 'vue-i18n'
import {nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {useAuthStore} from '@/stores/auth'
import {useColorScheme} from '@/composables/useColorScheme'
const {
entityKind,
entityId,
disabled = false,
} = defineProps<{
entityKind: ReactionKind,
entityId: number,
disabled?: boolean,
}>()
const authStore = useAuthStore()
const {t} = useI18n()
const reactionService = new ReactionService()
const {isDark} = useColorScheme()
const model = defineModel<IReactionPerEntity>()
async function addReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.create(reaction)
showEmojiPicker.value = false
if (typeof model.value === 'undefined') {
model.value = {}
}
if (typeof model.value[reaction.value] === 'undefined') {
model.value[reaction.value] = [authStore.info]
} else {
model.value[reaction.value].push(authStore.info)
}
}
async function removeReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.delete(reaction)
showEmojiPicker.value = false
const userIndex = model.value[reaction.value].findIndex(u => u.id === authStore.info?.id)
if (userIndex !== -1) {
model.value[reaction.value].splice(userIndex, 1)
}
if(model.value[reaction.value].length === 0) {
delete model.value[reaction.value]
}
}
function getReactionTooltip(users: IUser[], value: string) {
const names = users.map(u => getDisplayName(u))
if (names.length === 1) {
return t('reaction.reactedWith', {user: names[0], value})
}
if (names.length > 1 && names.length < 10) {
return t('reaction.reactedWithAnd', {
users: names.slice(0, names.length - 1).join(', '),
lastUser: names[names.length - 1],
value,
})
}
return t('reaction.reactedWithAndMany', {
users: names.slice(0, 10).join(', '),
num: names.length - 10,
value,
})
}
const showEmojiPicker = ref(false)
const emojiPickerRef = ref<HTMLElement | null>(null)
function hideEmojiPicker(e: MouseEvent) {
if (showEmojiPicker.value) {
closeWhenClickedOutside(e, emojiPickerRef.value.$el, () => showEmojiPicker.value = false)
}
}
onMounted(() => document.addEventListener('click', hideEmojiPicker))
onBeforeUnmount(() => document.removeEventListener('click', hideEmojiPicker))
const emojiPickerButtonRef = ref<HTMLElement | null>(null)
const reactionContainerRef = ref<HTMLElement | null>(null)
const emojiPickerPosition = ref()
function toggleEmojiPicker() {
if (!showEmojiPicker.value) {
const rect = emojiPickerButtonRef.value?.$el.getBoundingClientRect()
const container = reactionContainerRef.value?.getBoundingClientRect()
const left = rect.left - container.left + rect.width
emojiPickerPosition.value = {
left: left === 0 ? undefined : left,
}
}
nextTick(() => showEmojiPicker.value = !showEmojiPicker.value)
}
function hasCurrentUserReactedWithEmoji(value: string): boolean {
const user = model.value[value].find(u => u.id === authStore.info.id)
return typeof user !== 'undefined'
}
async function toggleReaction(value: string) {
if (hasCurrentUserReactedWithEmoji(value)) {
return removeReaction(value)
}
return addReaction(value)
}
</script>
<template>
<div
ref="reactionContainerRef"
class="reactions"
>
<BaseButton
v-for="(users, value) in (model as IReactionPerEntity)"
:key="'button' + value"
v-tooltip="getReactionTooltip(users, value)"
class="reaction-button"
:class="{'current-user-has-reacted': hasCurrentUserReactedWithEmoji(value)}"
:disabled
@click="toggleReaction(value)"
>
{{ value }} {{ users.length }}
</BaseButton>
<BaseButton
v-if="!disabled"
ref="emojiPickerButtonRef"
v-tooltip="$t('reaction.add')"
class="reaction-button"
@click.stop="toggleEmojiPicker"
>
<icon :icon="['far', 'face-laugh']" />
</BaseButton>
<CustomTransition name="fade">
<VuemojiPicker
v-if="showEmojiPicker"
ref="emojiPickerRef"
class="emoji-picker"
:style="{left: emojiPickerPosition?.left + 'px'}"
data-source="/emojis.json"
:is-dark="isDark"
@emojiClick="detail => addReaction(detail.unicode)"
/>
</CustomTransition>
</div>
</template>
<style scoped lang="scss">
.reaction-button {
margin-right: .25rem;
margin-bottom: .25rem;
padding: .175rem .5rem .15rem;
border: 1px solid var(--grey-400);
background: var(--grey-100);
border-radius: 100px;
font-size: .75rem;
&.current-user-has-reacted {
border-color: var(--primary);
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-light-l), 0.5);
}
}
.emoji-picker {
position: absolute;
z-index: 99;
margin-top: .5rem;
}
</style>

View File

@ -13,7 +13,7 @@
}"
>
<template v-if="icon">
<icon
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
@ -22,7 +22,7 @@
v-else
class="icon is-small"
>
<icon
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
@ -34,20 +34,20 @@
<script lang="ts">
const BUTTON_TYPES_MAP = {
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
} as const
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
export default {name: 'XButton'}
export default { name: 'XButton' }
</script>
<script setup lang="ts">
import {computed, useSlots} from 'vue'
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
// extending the props of the BaseButton
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
@ -76,38 +76,37 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
<style lang="scss" scoped>
.button {
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
line-height: 1;
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
&:hover {
box-shadow: var(--shadow-md);
}
&:hover {
box-shadow: var(--shadow-md);
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
}
.is-small {
@ -115,6 +114,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
}
.underline-none {
text-decoration: none !important;
text-decoration: none !important;
}
</style>

View File

@ -63,7 +63,6 @@
<div class="flatpickr-container">
<flat-pickr
ref="flatPickrRef"
v-model="flatPickrDate"
:config="flatPickerConfig"
/>
@ -71,7 +70,7 @@
</template>
<script lang="ts" setup>
import {computed, onBeforeUnmount, onMounted, type PropType, ref, toRef, watch} from 'vue'
import {ref, toRef, watch, computed, type PropType} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
@ -82,7 +81,7 @@ import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useI18n} from 'vue-i18n'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
@ -106,7 +105,6 @@ watch(
{immediate: true},
)
const flatPickrRef = ref<HTMLElement | null>(null)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -144,41 +142,6 @@ const flatPickrDate = computed({
},
})
onMounted(() => {
const inputs = flatPickrRef.value?.$el.parentNode.querySelectorAll('.numInputWrapper > input.numInput')
inputs.forEach(i => {
i.addEventListener('input', handleFlatpickrInput)
})
})
onBeforeUnmount(() => {
const inputs = flatPickrRef.value?.$el.parentNode.querySelectorAll('.numInputWrapper > input.numInput')
inputs.forEach(i => {
i.removeEventListener('input', handleFlatpickrInput)
})
})
// Flatpickr only returns a change event when the value in the input it's referring to changes.
// That means it will usually only trigger when the focus is moved out of the input field.
// This is fine most of the time. However, since we're displaying flatpickr in a popup,
// the whole html dom instance might get destroyed, before the change event had a
// chance to fire. In that case, it would not update the date value. To fix
// this, we're now listening on every change and bubble them up as soon
// as they happen.
function handleFlatpickrInput(e) {
const newDate = new Date(date?.value || 'now')
if (e.target.classList.contains('flatpickr-minute')) {
newDate.setMinutes(e.target.value)
}
if (e.target.classList.contains('flatpickr-hour')) {
newDate.setHours(e.target.value)
}
if (e.target.classList.contains('cur-year')) {
newDate.setFullYear(e.target.value)
}
flatPickrDate.value = newDate
}
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {

View File

@ -139,7 +139,7 @@
<BaseButton
v-tooltip="$t('input.editor.image')"
class="editor-toolbar__button"
@click="e => emit('imageUploadClicked', e)"
@click="openImagePicker"
>
<span class="icon">
<icon icon="fa-image" />
@ -347,14 +347,16 @@ const {
editor: Editor,
}>()
const emit = defineEmits(['imageUploadClicked'])
const tableMode = ref(false)
function toggleTableMode() {
tableMode.value = !tableMode.value
}
function openImagePicker() {
document.getElementById('tiptap__image-upload').click()
}
function setLink(event) {
setLinkInEditor(event.target.getBoundingClientRect(), editor)
}

View File

@ -6,7 +6,7 @@
<EditorToolbar
v-if="editor && isEditing"
:editor="editor"
@imageUploadClicked="triggerImageInput"
:upload-callback="uploadCallback"
/>
<BubbleMenu
v-if="editor && isEditing"
@ -338,12 +338,10 @@ const editor = useEditor({
HardBreak.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.commands.setHardBreak(),
'Mod-Enter': () => {
if (contentHasChanged.value) {
bubbleSave()
}
return true
},
}
},
@ -376,7 +374,7 @@ const editor = useEditor({
Typography,
Underline,
Link.configure({
openOnClick: false,
openOnClick: true,
validate: (href: string) => /^https?:\/\//.test(href),
}),
Table.configure({
@ -491,15 +489,6 @@ function uploadAndInsertFiles(files: File[] | FileList) {
})
}
function triggerImageInput(event) {
if (typeof uploadCallback !== 'undefined') {
uploadInputRef.value?.click()
return
}
addImage(event)
}
async function addImage(event) {
if (typeof uploadCallback !== 'undefined') {
@ -533,20 +522,16 @@ onMounted(async () => {
await nextTick()
if (typeof uploadCallback !== 'undefined') {
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.addEventListener('paste', handleImagePaste)
}
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.addEventListener('paste', handleImagePaste)
setModeAndValue(modelValue)
})
onBeforeUnmount(() => {
nextTick(() => {
if (typeof uploadCallback !== 'undefined') {
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.removeEventListener('paste', handleImagePaste)
}
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.removeEventListener('paste', handleImagePaste)
})
if (editShortcut !== '') {
document.removeEventListener('keydown', setFocusToEditor)
@ -573,10 +558,6 @@ function handleImagePaste(event) {
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event) {
if (event.target.shadowRoot) {
return
}
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString !== editShortcut ||
@ -615,7 +596,7 @@ watch(
() => isEditing.value,
async editing => {
await nextTick()
let checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
// For some reason, this works when we check a second time.
@ -818,7 +799,7 @@ watch(
td,
th {
min-width: 1em;
border: 2px solid var(--grey-300) !important;
border: 2px solid #ced4da;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
@ -832,7 +813,7 @@ watch(
th {
font-weight: bold;
text-align: left;
background-color: var(--grey-200);
background-color: #f1f3f5;
}
.selectedCell:after {
@ -893,14 +874,8 @@ ul[data-type='taskList'] {
padding: 0;
margin-left: 0;
li[data-checked='true'] {
color: var(--grey-500);
text-decoration: line-through;
}
li {
display: flex;
margin-top: 0.25rem;
> label {
flex: 0 0 auto;

View File

@ -87,7 +87,7 @@ import {
faStar,
faSun,
faTimesCircle,
faCircleQuestion, faFaceLaugh,
faCircleQuestion,
} from '@fortawesome/free-regular-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
@ -186,7 +186,6 @@ library.add(faXmarksLines)
library.add(faFont)
library.add(faRulerHorizontal)
library.add(faUnderline)
library.add(faFaceLaugh)
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -62,7 +62,7 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.view',
available: (route) => route.name === 'project.kanban',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',

View File

@ -188,6 +188,12 @@ $modal-width: 1024px;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}

View File

@ -1,7 +1,7 @@
<template>
<slot
name="trigger"
:is-open="openValue"
:is-open="open"
:toggle="toggle"
:close="close"
/>
@ -9,13 +9,13 @@
ref="popup"
class="popup"
:class="{
'is-open': openValue,
'has-overflow': props.hasOverflow && openValue
'is-open': open,
'has-overflow': props.hasOverflow && open
}"
>
<slot
name="content"
:is-open="openValue"
:is-open="open"
:toggle="toggle"
:close="close"
/>
@ -37,29 +37,29 @@ const props = defineProps({
},
})
const emit = defineEmits(['close'])
watch(
() => props.open,
nowOpen => {
openValue.value = nowOpen
open.value = nowOpen
},
)
const openValue = ref(false)
const emit = defineEmits(['close'])
const open = ref(false)
const popup = ref<HTMLElement | null>(null)
function close() {
openValue.value = false
open.value = false
emit('close')
}
function toggle() {
openValue.value = !openValue.value
open.value = !open.value
}
onClickOutside(popup, () => {
if (!openValue.value) {
if (!open.value) {
return
}
close()

View File

@ -6,23 +6,44 @@
<h1 class="project-title-print">
{{ getProjectTitle(currentProject) }}
</h1>
<div
class="switch-view-container d-print-none"
:class="{'is-justify-content-flex-end': views.length === 1}"
>
<div
v-if="views.length > 1"
class="switch-view"
>
<div class="switch-view-container d-print-none">
<div class="switch-view">
<BaseButton
v-for="v in views"
:key="v.id"
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToListView')"
class="switch-view-button"
:class="{'is-active': v.id === viewId}"
:to="{ name: 'project.view', params: { projectId, viewId: v.id } }"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
>
{{ getViewTitle(v) }}
{{ $t('project.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
@ -42,7 +63,7 @@
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@ -58,27 +79,26 @@ import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import {useI18n} from 'vue-i18n'
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const {t} = useI18n()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed<IProject>(() => {
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
@ -88,15 +108,13 @@ const currentProject = computed<IProject>(() => {
})
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
const views = computed(() => projectStore.projects[projectId]?.views)
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => projectId,
() => props.projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
@ -112,11 +130,11 @@ watch(
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = projectId
loadedProjectId.value = props.projectId
return
}
console.debug('Loading project, $route.params =', route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
@ -131,51 +149,31 @@ watch(
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = projectId
loadedProjectId.value = props.projectId
}
},
{immediate: true},
)
function getViewTitle(view: IProjectView) {
switch (view.title) {
case 'List':
return t('project.list.title')
case 'Gantt':
return t('project.gantt.title')
case 'Table':
return t('project.table.title')
case 'Kanban':
return t('project.kanban.title')
}
return view.title
}
</script>
<style lang="scss" scoped>
.switch-view-container {
min-height: $switch-view-height;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
@media screen and (max-width: $tablet) {
justify-content: center;
flex-direction: column;
}
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
padding: .5rem;
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
@ -203,7 +201,7 @@ function getViewTitle(view: IProjectView) {
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
margin-bottom: 1rem;
}
.project-title-print {
@ -211,7 +209,7 @@ function getViewTitle(view: IProjectView) {
font-size: 1.75rem;
text-align: center;
margin-bottom: .5rem;
@media print {
display: block;
}

View File

@ -15,7 +15,7 @@ function initState(value: string) {
:init-state="initState('dueDate < now && done = false && dueDate > now/w+1w')"
>
<template #default="{state}">
<FilterInput v-model="state.value" />
<FilterInput v-model="state.value"/>
</template>
</Variant>
</Story>

View File

@ -1,48 +1,23 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
import {nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from '@/services/user'
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
import {useLabelStore} from '@/stores/labels'
import XLabel from '@/components/tasks/partials/label.vue'
import User from '@/components/misc/user.vue'
import ProjectUserService from '@/services/projectUsers'
import {useProjectStore} from '@/stores/projects'
import {
ASSIGNEE_FIELDS,
AUTOCOMPLETE_FIELDS,
AVAILABLE_FILTER_FIELDS,
DATE_FIELDS,
FILTER_JOIN_OPERATOR,
FILTER_OPERATORS,
FILTER_OPERATORS_REGEX,
getFilterFieldRegexPattern,
LABEL_FIELDS,
} from '@/helpers/filters'
import {useDebounceFn} from '@vueuse/core'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
projectId,
inputLabel = undefined,
} = defineProps<{
modelValue: string,
projectId?: number,
inputLabel?: string,
}>()
const emit = defineEmits(['update:modelValue', 'blur'])
const filterQuery = ref<string>('')
const filterQuery = ref('')
const {
textarea: filterInput,
height,
} = useAutoHeightTextarea(filterQuery)
const id = ref(createRandomID())
watch(
() => modelValue,
() => {
@ -51,17 +26,49 @@ watch(
{immediate: true},
)
watch(
() => filterQuery.value,
() => {
if (filterQuery.value !== modelValue) {
emit('update:modelValue', filterQuery.value)
}
},
)
const userService = new UserService()
const projectUserService = new ProjectUserService()
const dateFields = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
const dateFieldsRegex = '(' + dateFields.join('|') + ')'
const assigneeFields = [
'assignees',
]
const availableFilterFields = [
'done',
'priority',
'usePriority',
'percentDone',
'labels',
...dateFields,
...assigneeFields,
]
const filterOperators = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
const filterJoinOperators = [
'&&',
'||',
'(',
')',
]
function escapeHtml(unsafe: string): string {
return unsafe
@ -81,87 +88,190 @@ function unEscapeHtml(unsafe: string): string {
.replace(/&#039;/g, '\'')
}
const highlightedFilterQuery = computed(() => {
const TOKEN_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
function getHighlightedFilterQuery() {
let highlighted = escapeHtml(filterQuery.value)
DATE_FIELDS
dateFields
.forEach(o => {
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
const pattern = new RegExp(o + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position) => {
if (typeof value === 'undefined') {
value = ''
value = ' '
}
let endPadding = ''
if(value.endsWith(' ')) {
const fullLength = value.length
value = value.trimEnd()
const numberOfRemovedSpaces = fullLength - value.length
endPadding = endPadding.padEnd(numberOfRemovedSpaces, ' ')
}
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>${endPadding}`
return `${o} ${token} <button class="button is-primary filter-query__date_value" data-position="${position}">${value}</button>`
})
})
ASSIGNEE_FIELDS
assigneeFields
.forEach(f => {
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
const pattern = new RegExp(f + '\\s*' + TOKEN_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
if (typeof value === 'undefined') {
value = ''
}
return `${f} ${token} <span class="filter-query__assignee_value">${value}<span>`
})
})
FILTER_JOIN_OPERATOR
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
})
LABEL_FIELDS
.forEach(f => {
const pattern = getFilterFieldRegexPattern(f)
highlighted = highlighted.replaceAll(pattern, (match, prefix, operator, space, value) => {
const id = createRandomID(32)
if (typeof value === 'undefined') {
value = ''
}
userService.getAll({}, {s: value}).then(users => {
if (users.length > 0) {
const displayName = getDisplayName(users[0])
const nameTag = document.createElement('span')
nameTag.innerText = displayName
let labelTitles = [value.trim()]
if (operator === 'in' || operator === '?=') {
labelTitles = value.split(',').map(v => v.trim())
}
const avatar = document.createElement('img')
avatar.src = getAvatarUrl(users[0], 20)
avatar.height = 20
avatar.width = 20
avatar.alt = displayName
const labelsHtml: string[] = []
labelTitles.forEach(t => {
const label = labelStore.getLabelByExactTitle(t) || undefined
labelsHtml.push(`<span class="filter-query__label_value" style="background-color: ${label?.hexColor}; color: ${label?.textColor}">${label?.title ?? t}</span>`)
// TODO: caching
nextTick(() => {
const assigneeValue = document.getElementById(id)
assigneeValue.innerText = ''
assigneeValue?.appendChild(avatar)
assigneeValue?.appendChild(nameTag)
})
}
})
const endSpace = value.endsWith(' ') ? ' ' : ''
return `${f} ${operator} ${labelsHtml.join(', ')}${endSpace}`
return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>`
})
})
FILTER_OPERATORS
filterOperators
.map(o => ` ${escapeHtml(o)} `)
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
})
AVAILABLE_FILTER_FIELDS.forEach(f => {
filterJoinOperators
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
})
availableFilterFields.forEach(f => {
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
})
return highlighted
})
}
const currentOldDatepickerValue = ref('')
const currentDatepickerValue = ref('')
const currentDatepickerPos = ref()
const datePickerPopupOpen = ref(false)
watch(
() => highlightedFilterQuery.value,
async () => {
await nextTick()
function updateDateInQuery(newDate: string) {
// Need to escape and unescape the query because the positions are based on the escaped query
let escaped = escapeHtml(filterQuery.value)
escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
updateQueryHighlight()
}
function getCharacterOffsetWithin(element: HTMLInputElement, isStart: boolean): number {
let range = document.createRange()
let sel = window.getSelection()
if (sel.rangeCount > 0) {
let originalRange = sel.getRangeAt(0)
range.selectNodeContents(element)
range.setEnd(
isStart ? originalRange.startContainer : originalRange.endContainer,
isStart ? originalRange.startOffset : originalRange.endOffset,
)
const rangeLength = range.toString().length
const originalLength = originalRange.toString().length
return rangeLength - (isStart ? 0 : originalLength)
}
return 0 // No selection
}
function saveSelectionOffsets(element: HTMLInputElement) {
return {
start: getCharacterOffsetWithin(element, true),
end: getCharacterOffsetWithin(element, false),
}
}
function setSelectionByCharacterOffsets(element: HTMLElement, startOffset: number, endOffset: number) {
let charIndex = 0, range = document.createRange()
const sel = window.getSelection()
console.log({startOffset, endOffset})
range.setStart(element, 0)
range.collapse(true)
let foundStart = false
const allTextNodes: ChildNode[] = []
element.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) {
allTextNodes.push(n)
}
n.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
allTextNodes.push(child)
}
})
})
allTextNodes.forEach(node => {
const nextCharIndex = charIndex + node.textContent.length
let addition = node.textContent === ' ' ? 1 : 0
if (!foundStart && startOffset >= charIndex && startOffset <= nextCharIndex) {
range.setStart(node, startOffset - charIndex + addition)
foundStart = true // Start position found
}
if (foundStart && endOffset >= charIndex && endOffset <= nextCharIndex) {
if (node.parentNode?.nodeName === 'BUTTON') {
node.parentNode?.focus()
range.setStartAfter(node.parentNode)
range.setEndAfter(node.parentNode)
return
}
range.setEnd(node, endOffset - charIndex + addition)
}
charIndex = nextCharIndex // Update charIndex to the next position
})
// FIXME: This kind of works for the first literal but breaks as soon as you type another query after the first it breaks
sel.removeAllRanges()
sel.addRange(range)
}
function updateQueryStringFromInput(e) {
filterQuery.value = e.target.innerText
const element = e.target
const offsets = saveSelectionOffsets(element)
if (offsets) {
updateQueryHighlight()
setSelectionByCharacterOffsets(element, offsets.start, offsets.end)
} else {
updateQueryHighlight()
}
}
const queryInputRef = ref<HTMLInputElement | null>(null)
function updateQueryHighlight() {
// Updating the query value in a function instead of a computed gives us more control about the timing
queryInputRef.value.innerHTML = getHighlightedFilterQuery()
nextTick(() => {
document.querySelectorAll('button.filter-query__date_value')
.forEach(b => {
b.addEventListener('click', event => {
@ -175,161 +285,34 @@ watch(
datePickerPopupOpen.value = true
})
})
},
{immediate: true},
)
function updateDateInQuery(newDate: string) {
// Need to escape and unescape the query because the positions are based on the escaped query
let escaped = escapeHtml(filterQuery.value)
escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
}
const autocompleteMatchPosition = ref(0)
const autocompleteMatchText = ref('')
const autocompleteResultType = ref<'labels' | 'assignees' | 'projects' | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteResults = ref<any[]>([])
const labelStore = useLabelStore()
const projectStore = useProjectStore()
function handleFieldInput() {
const cursorPosition = filterInput.value.selectionStart
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
autocompleteResults.value = []
AUTOCOMPLETE_FIELDS.forEach(field => {
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()]+\\1?)?$', 'ig')
const match = pattern.exec(textUpToCursor)
if (match !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let search = keyword
if (operator === 'in' || operator === '?=') {
const keywords = keyword.split(',')
search = keywords[keywords.length - 1].trim()
}
if (matched.startsWith('label')) {
autocompleteResultType.value = 'labels'
autocompleteResults.value = labelStore.filterLabelsByQuery([], search)
}
if (matched.startsWith('assignee')) {
autocompleteResultType.value = 'assignees'
if (projectId) {
projectUserService.getAll({projectId}, {s: search})
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
} else {
userService.getAll({}, {s: search})
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
}
}
if (!projectId && matched.startsWith('project')) {
autocompleteResultType.value = 'projects'
autocompleteResults.value = projectStore.searchProject(search)
}
autocompleteMatchText.value = keyword
autocompleteMatchPosition.value = match.index + prefix.length - 1 + keyword.replace(search, '').length
}
}
})
}
function autocompleteSelect(value) {
filterQuery.value = filterQuery.value.substring(0, autocompleteMatchPosition.value + 1) +
(autocompleteResultType.value === 'assignees'
? value.username
: value.title) +
filterQuery.value.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
autocompleteResults.value = []
}
// The blur from the textarea might happen before the replacement after autocomplete select was done.
// That caused listeners to try and replace values earlier, resulting in broken queries.
const blurDebounced = useDebounceFn(() => emit('blur'), 500)
</script>
<template>
<div class="field">
<label
class="label"
:for="id"
>
{{ inputLabel ?? $t('filters.query.title') }}
</label>
<AutocompleteDropdown
:options="autocompleteResults"
@blur="filterInput?.blur()"
@update:modelValue="autocompleteSelect"
>
<template
#input="{ onKeydown, onFocusField }"
>
<div class="control filter-input">
<textarea
:id
ref="filterInput"
v-model="filterQuery"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="input"
:class="{'has-autocomplete-results': autocompleteResults.length > 0}"
:placeholder="$t('filters.query.placeholder')"
@input="handleFieldInput"
@focus="onFocusField"
@keydown="onKeydown"
@blur="blurDebounced"
/>
<div
class="filter-input-highlight"
:style="{'height': height}"
v-html="highlightedFilterQuery"
/>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:modelValue="updateDateInQuery"
/>
</div>
</template>
<template
#result="{ item }"
>
<XLabel
v-if="autocompleteResultType === 'labels'"
:label="item"
/>
<User
v-else-if="autocompleteResultType === 'assignees'"
:user="item"
:avatar-size="25"
/>
<template v-else>
{{ item.title }}
</template>
</template>
</AutocompleteDropdown>
<label class="label">{{ $t('filters.query.title') }}</label>
<div class="control filter-input">
<div
class="input filter-input-highlight"
:style="{'height': height}"
contenteditable="true"
@input="updateQueryStringFromInput"
ref="queryInputRef"
></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:model-value="updateDateInQuery"
/>
</div>
{{ filterQuery }}
</div>
</template>
<style lang="scss">
.filter-input-highlight {
&, button.filter-query__date_value {
color: var(--card-color);
}
span {
&.filter-query__field {
color: var(--code-literal);
@ -344,55 +327,46 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
}
&.filter-query__date_value_placeholder {
padding: .125rem .25rem;
display: inline-block;
color: transparent;
}
&.filter-query__assignee_value, &.filter-query__label_value {
&.filter-query__assignee_value {
padding: .125rem .25rem;
border-radius: $radius;
background-color: var(--grey-200);
color: var(--grey-700);
display: inline-flex;
align-items: center;
> img {
margin-right: .25rem;
}
}
}
button.filter-query__date_value {
padding: .125rem .25rem;
border-radius: $radius;
position: absolute;
margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem;
padding: 0;
border: 0;
background: transparent;
font-size: 1rem;
cursor: pointer;
line-height: 1.5;
}
}
</style>
<style lang="scss" scoped>
.filter-input {
position: relative;
//position: relative;
textarea {
position: absolute;
background: transparent !important;
resize: none;
text-fill-color: transparent;
-webkit-text-fill-color: transparent;
&::placeholder {
text-fill-color: var(--input-placeholder-color);
-webkit-text-fill-color: var(--input-placeholder-color);
}
&.has-autocomplete-results {
border-radius: var(--input-radius) var(--input-radius) 0 0;
}
//position: absolute;
//text-fill-color: transparent;
//-webkit-text-fill-color: transparent;
//background: transparent !important;
//resize: none;
}
.filter-input-highlight {
background: var(--white);
height: 2.5em;
line-height: 1.5;
padding: .5em .75em;

View File

@ -1,80 +0,0 @@
<script setup lang="ts">
import {ref} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const showDocs = ref(false)
</script>
<template>
<BaseButton
class="has-text-primary"
@click="showDocs = !showDocs"
>
{{ $t('filters.query.help.link') }}
</BaseButton>
<Transition>
<div
v-if="showDocs"
class="content"
>
<p>{{ $t('filters.query.help.intro') }}</p>
<ul>
<li><code>done</code>: {{ $t('filters.query.help.fields.done') }}</li>
<li><code>priority</code>: {{ $t('filters.query.help.fields.priority') }}</li>
<li><code>percentDone</code>: {{ $t('filters.query.help.fields.percentDone') }}</li>
<li><code>dueDate</code>: {{ $t('filters.query.help.fields.dueDate') }}</li>
<li><code>startDate</code>: {{ $t('filters.query.help.fields.startDate') }}</li>
<li><code>endDate</code>: {{ $t('filters.query.help.fields.endDate') }}</li>
<li><code>doneAt</code>: {{ $t('filters.query.help.fields.doneAt') }}</li>
<li><code>assignees</code>: {{ $t('filters.query.help.fields.assignees') }}</li>
<li><code>labels</code>: {{ $t('filters.query.help.fields.labels') }}</li>
<li><code>project</code>: {{ $t('filters.query.help.fields.project') }}</li>
</ul>
<p>{{ $t('filters.query.help.canUseDatemath') }}</p>
<p>{{ $t('filters.query.help.operators.intro') }}</p>
<ul>
<li><code>!=</code>: {{ $t('filters.query.help.operators.notEqual') }}</li>
<li><code>=</code>: {{ $t('filters.query.help.operators.equal') }}</li>
<li><code>&gt;</code>: {{ $t('filters.query.help.operators.greaterThan') }}</li>
<li><code>&gt;=</code>: {{ $t('filters.query.help.operators.greaterThanOrEqual') }}</li>
<li><code>&lt;</code>: {{ $t('filters.query.help.operators.lessThan') }}</li>
<li><code>&lt;=</code>: {{ $t('filters.query.help.operators.lessThanOrEqual') }}</li>
<li><code>like</code>: {{ $t('filters.query.help.operators.like') }}</li>
<li><code>in</code>: {{ $t('filters.query.help.operators.in') }}</li>
</ul>
<p>{{ $t('filters.query.help.logicalOperators.intro') }}</p>
<ul>
<li><code>&amp;&amp;</code>: {{ $t('filters.query.help.logicalOperators.and') }}</li>
<li><code>||</code>: {{ $t('filters.query.help.logicalOperators.or') }}</li>
<li><code>(</code> and <code>)</code>: {{ $t('filters.query.help.logicalOperators.parentheses') }}</li>
</ul>
<p>{{ $t('filters.query.help.examples.intro') }}</p>
<ul>
<li><code>priority = 4</code>: {{ $t('filters.query.help.examples.priorityEqual') }}</li>
<li><code>dueDate &lt; now</code>: {{ $t('filters.query.help.examples.dueDatePast') }}</li>
<li>
<code>done = false &amp;&amp; priority &gt;= 3</code>:
{{ $t('filters.query.help.examples.undoneHighPriority') }}
</li>
<li><code>assignees in [user1, user2]</code>: {{ $t('filters.query.help.examples.assigneesIn') }}</li>
<li>
<code>(priority = 1 || priority = 2) &amp;&amp; dueDate &lt;= now</code>:
{{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}
</li>
</ul>
</div>
</Transition>
</template>
<style scoped lang="scss">
.v-enter-active,
.v-leave-active {
transition: all $transition-duration ease;
}
.v-enter-from,
.v-leave-to {
transform: scaleY(0);
}
</style>

View File

@ -30,7 +30,7 @@
>
<icon icon="filter" />
</span>
{{ getProjectTitle(project) }}
{{ project.title }}
</div>
<BaseButton
class="project-button"
@ -59,7 +59,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useProjectBackground} from './useProjectBackground'
import {useProjectStore} from '@/stores/projects'
import {getProjectTitle} from '@/helpers/getProjectTitle'
const {
project,

View File

@ -63,10 +63,6 @@ const filteredProjects = computed(() => {
@media screen and (min-width: $widescreen) {
--project-grid-columns: 5;
.project-grid-item:nth-child(6) {
display: none;
}
}
}

View File

@ -1,8 +1,14 @@
<template>
<x-button
v-if="hasFilters"
variant="secondary"
@click="clearFilters"
>
{{ $t('filters.clear') }}
</x-button>
<x-button
variant="secondary"
icon="filter"
:class="{'has-filters': hasFilters}"
@click="() => modalOpen = true"
>
{{ $t('filters.title') }}
@ -19,8 +25,6 @@
v-model="value"
:has-title="true"
class="filter-popup"
@update:modelValue="emitChanges"
@showResultsButtonClicked="() => modalOpen = false"
/>
</modal>
</template>
@ -30,35 +34,58 @@ import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
import {type TaskFilterParams} from '@/services/taskCollection'
import {getDefaultParams} from '@/composables/useTaskList'
const props = defineProps(['modelValue'])
const props = defineProps({
modelValue: {
required: true,
},
})
const emit = defineEmits(['update:modelValue'])
const value = ref<TaskFilterParams>({})
const value = computed({
get() {
return props.modelValue
},
set(value) {
if(props.modelValue === value) {
return
}
emit('update:modelValue', value)
},
})
watch(
() => props.modelValue,
(modelValue: TaskFilterParams) => {
(modelValue) => {
value.value = modelValue
},
{immediate: true},
)
function emitChanges(newValue: TaskFilterParams) {
emit('update:modelValue', {
...value.value,
filter: newValue.filter,
s: newValue.s,
})
}
const hasFilters = computed(() => {
return value.value.filter !== '' ||
value.value.s !== ''
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
})
const modalOpen = ref(false)
function clearFilters() {
value.value = {...getDefaultParams()}
}
</script>
<style scoped lang="scss">
@ -69,21 +96,4 @@ const modalOpen = ref(false)
margin: 2rem 0 1rem;
}
}
$filter-bubble-size: .75rem;
.has-filters {
position: relative;
&::after {
content: '';
position: absolute;
top: math.div($filter-bubble-size, -2);
right: math.div($filter-bubble-size, -2);
width: $filter-bubble-size;
height: $filter-bubble-size;
border-radius: 100%;
background: var(--primary);
}
}
</style>

View File

@ -2,43 +2,204 @@
<card
class="filters has-overflow"
:title="hasTitle ? $t('filters.title') : ''"
role="search"
>
<FilterInput
v-model="filterQuery"
:project-id="projectId"
@blur="change()"
/>
<div class="field is-flex is-flex-direction-column">
<Fancycheckbox
v-model="params.filter_include_nulls"
@blur="change()"
@update:modelValue="change()"
>
{{ $t('filters.attributes.includeNulls') }}
</Fancycheckbox>
<Fancycheckbox
v-model="filters.requireAllFilters"
@update:modelValue="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</Fancycheckbox>
<Fancycheckbox
v-model="filters.done"
@update:modelValue="setDoneFilter"
>
{{ $t('filters.attributes.showDoneTasks') }}
</Fancycheckbox>
<Fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:modelValue="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</Fancycheckbox>
</div>
<FilterInput v-model="filterQuery"/>
<Autocomplete
:options="filteredFruits"
suggestion="Type: Blueberry"
v-model="selectedValue"
/>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
<input
v-model="params.s"
class="input"
:placeholder="$t('misc.search')"
@blur="change()"
@keyup.enter="change()"
>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<PrioritySelect
v-model.number="filters.priority"
:disabled="!filters.usePriority || undefined"
@update:modelValue="setPriority"
/>
<Fancycheckbox
v-model="filters.usePriority"
@update:modelValue="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<div class="control single-value-control">
<PercentDoneSelect
v-model.number="filters.percentDone"
:disabled="!filters.usePercentDone || undefined"
@update:modelValue="setPercentDoneFilter"
/>
<Fancycheckbox
v-model="filters.usePercentDone"
@update:modelValue="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.dueDate"
@update:modelValue="values => setDateFilter('due_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.startDate"
@update:modelValue="values => setDateFilter('start_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.endDate"
@update:modelValue="values => setDateFilter('end_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.reminders"
@update:modelValue="values => setDateFilter('reminders', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<FilterInputDocs />
<div class="field">
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<div class="control">
<SelectUser
v-model="entities.users"
@select="changeMultiselectFilter('users', 'assignees')"
@remove="changeMultiselectFilter('users', 'assignees')"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<EditLabels
v-model="entities.labels"
:creatable="false"
@update:modelValue="changeLabelFilter"
/>
</div>
</div>
<template
v-if="hasFooter"
#footer
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"
>
<x-button
variant="secondary"
class="mr-2"
:disabled="filterQuery === ''"
@click.prevent.stop="clearFiltersAndEmit"
>
{{ $t('filters.clear') }}
</x-button>
<x-button
variant="primary"
@click.prevent.stop="changeAndEmitButton"
>
{{ $t('filters.showResults') }}
</x-button>
<div class="field">
<label class="label">{{ $t('project.projects') }}</label>
<div class="control">
<SelectProject
v-model="entities.projects"
:project-filter="p => p.id > 0"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
/>
</div>
</div>
</template>
</card>
</template>
@ -48,111 +209,533 @@ export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {useRoute} from 'vue-router'
import type {TaskFilterParams} from '@/services/taskCollection'
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs} from 'vue'
import {camelCase} from 'camel-case'
import {watchDebounced} from '@vueuse/core'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {IProject} from '@/modelTypes/IProject'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {FILTER_OPERATORS, transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
import FilterInputDocs from '@/components/project/partials/FilterInputDocs.vue'
const {
hasTitle = false,
hasFooter = true,
modelValue,
} = defineProps<{
hasTitle?: boolean,
hasFooter?: boolean,
modelValue: TaskFilterParams,
}>()
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue'
const emit = defineEmits(['update:modelValue', 'showResultsButtonClicked'])
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
const route = useRoute()
const projectId = computed(() => {
if (route.name?.startsWith('project.')) {
return Number(route.params.projectId)
}
import UserService from '@/services/user'
import ProjectService from '@/services/project'
return undefined
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import Autocomplete from '@/components/input/Autocomplete.vue'
const selectedValue = ref('')
const filteredFruits = computed(() => {
const vals = (selectedValue.value || '').toLowerCase().split(' ')
return FRUITS
.filter(f => f.toLowerCase().startsWith(vals[vals.length - 1]))
.sort()
})
const params = ref<TaskFilterParams>({
const FRUITS = [
'Apple',
'Apricot',
'Avocado',
'Banana',
'Bilberry',
'Blackberry',
'Blackcurrant',
'Blueberry',
'Boysenberry',
'Buddha\'s hand (fingered citron)',
'Crab apples',
'Currant',
'Cherry',
'Cherimoya',
'Chico fruit',
'Cloudberry',
'Coconut',
'Cranberry',
'Cucumber',
'Custard apple',
'Damson',
'Date',
'Dragonfruit',
'Durian',
'Elderberry',
'Feijoa',
'Fig',
'Goji berry',
'Gooseberry',
'Grape',
'Raisin',
'Grapefruit',
'Guava',
'Honeyberry',
'Huckleberry',
'Jabuticaba',
'Jackfruit',
'Jambul',
'Jujube',
'Juniper berry',
'Kiwano',
'Kiwifruit',
'Kumquat',
'Lemon',
'Lime',
'Loquat',
'Longan',
'Lychee',
'Mango',
'Mangosteen',
'Marionberry',
'Melon',
'Cantaloupe',
'Honeydew',
'Watermelon',
'Miracle fruit',
'Mulberry',
'Nectarine',
'Nance',
'Olive',
'Orange',
'Blood orange',
'Clementine',
'Mandarine',
'Tangerine',
'Papaya',
'Passionfruit',
'Peach',
'Pear',
'Persimmon',
'Plantain',
'Plum',
'Prune (dried plum)',
'Pineapple',
'Plumcot (or Pluot)',
'Pomegranate',
'Pomelo',
'Purple mangosteen',
'Quince',
'Raspberry',
'Salmonberry',
'Rambutan',
'Redcurrant',
'Salal berry',
'Salak',
'Satsuma',
'Soursop',
'Star fruit',
'gonzoberry',
'Strawberry',
'Tamarillo',
'Tamarind',
'Ugli fruit',
'Yuzu',
]
const props = defineProps({
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
const DEFAULT_PARAMS = {
sort_by: [],
order_by: [],
filter: '',
filter_include_nulls: false,
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_include_nulls: true,
filter_concat: 'or',
s: '',
})
} as const
// FIXME: use params
const filterQuery = ref('')
watch(
() => [params.value.filter, params.value.s],
() => {
const filter = params.value.filter || ''
const s = params.value.s || ''
filterQuery.value = filter || s
},
)
// Using watchDebounced to prevent the filter re-triggering itself.
watch(
() => modelValue,
(value: TaskFilterParams) => {
const val = {...value}
val.filter = transformFilterStringFromApi(
val?.filter || '',
labelId => labelStore.getLabelById(labelId)?.title,
projectId => projectStore.projects[projectId]?.title || null,
)
params.value = val
},
{immediate: true},
)
const DEFAULT_FILTERS = {
done: false,
dueDate: '',
requireAllFilters: false,
priority: 0,
usePriority: false,
startDate: '',
endDate: '',
percentDone: 0,
usePercentDone: false,
reminders: '',
assignees: '',
labels: '',
project_id: '',
} as const
const {modelValue} = toRefs(props)
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const params = ref({...DEFAULT_PARAMS})
const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
projects: IProject[]
}
type EntityType = 'users' | 'labels' | 'projects'
const entities: Entities = reactive({
users: [],
labels: [],
projects: [],
})
onMounted(() => {
filters.value.requireAllFilters = params.value.filter_concat === 'and'
})
// Using watchDebounced to prevent the filter re-triggering itself.
// FIXME: Only here until this whole component changes a lot with the new filter syntax.
watchDebounced(
modelValue,
(value) => {
// FIXME: filters should only be converted to snake case in the last moment
params.value = objectToSnakeCase(value)
prepareFilters()
},
{immediate: true, debounce: 500, maxWait: 1000},
)
const sortAlphabetically = computed({
get() {
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
params.value.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
change()
},
})
function change() {
const filter = transformFilterStringForApi(
filterQuery.value,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
)
let s = ''
// When the filter does not contain any filter tokens, assume a simple search and redirect the input
const hasFilterQueries = FILTER_OPERATORS.find(o => filter.includes(o)) || false
if (!hasFilterQueries) {
s = filter
}
const newParams = {
...params.value,
filter: s === '' ? filter : '',
s,
}
if (JSON.stringify(modelValue) === JSON.stringify(newParams)) {
return
}
const newParams = {...params.value}
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
emit('update:modelValue', newParams)
}
function changeAndEmitButton() {
change()
emit('showResultsButtonClicked')
function prepareFilters() {
prepareDone()
prepareDate('due_date', 'dueDate')
prepareDate('start_date', 'startDate')
prepareDate('end_date', 'endDate')
prepareSingleValue('priority', 'priority', 'usePriority', true)
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders', 'reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareProjectsFilter()
prepareSingleValue('labels')
const newLabels = typeof filters.value.labels === 'string'
? filters.value.labels
: ''
const labelIds = newLabels.split(',').map(i => parseInt(i))
entities.labels = labelStore.getLabelsByIds(labelIds)
}
function clearFiltersAndEmit() {
filterQuery.value = ''
changeAndEmitButton()
function removePropertyFromFilter(filterName) {
// Because of the way arrays work, we can only ever remove one element at once.
// To remove multiple filter elements of the same name this function has to be called multiple times.
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName) {
params.value.filter_by.splice(i, 1)
params.value.filter_comparator.splice(i, 1)
params.value.filter_value.splice(i, 1)
break
}
}
}
function setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
let foundEnd = false
params.value.filter_by.forEach((f, i) => {
if (f === filterName && params.value.filter_comparator[i] === 'greater_equals') {
foundStart = true
params.value.filter_value[i] = dateFrom
}
if (f === filterName && params.value.filter_comparator[i] === 'less_equals') {
foundEnd = true
params.value.filter_value[i] = dateTo
}
})
if (!foundStart) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push('greater_equals')
params.value.filter_value.push(dateFrom)
}
if (!foundEnd) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push('less_equals')
params.value.filter_value.push(dateTo)
}
filters.value[camelCase(filterName)] = {
// Passing the dates as string values avoids an endless loop between values changing
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
// datepicker (because there's a new date instance every time this function gets called).
// See https://kolaente.dev/vikunja/frontend/issues/2384
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
}
change()
return
}
removePropertyFromFilter(filterName)
removePropertyFromFilter(filterName)
change()
}
function prepareDate(filterName: string, variableName: 'dueDate' | 'startDate' | 'endDate' | 'reminders') {
if (typeof params.value.filter_by === 'undefined') {
return
}
let foundDateStart: boolean | string = false
let foundDateEnd: boolean | string = false
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'greater_equals') {
foundDateStart = i
}
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'less_equals') {
foundDateEnd = i
}
if (foundDateStart !== false && foundDateEnd !== false) {
break
}
}
if (foundDateStart !== false && foundDateEnd !== false) {
const startDate = new Date(params.value.filter_value[foundDateStart])
const endDate = new Date(params.value.filter_value[foundDateEnd])
filters.value[variableName] = {
dateFrom: !isNaN(startDate)
? `${startDate.getUTCFullYear()}-${startDate.getUTCMonth() + 1}-${startDate.getUTCDate()}`
: params.value.filter_value[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getUTCFullYear()}-${endDate.getUTCMonth() + 1}-${endDate.getUTCDate()}`
: params.value.filter_value[foundDateEnd],
}
}
}
function setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {
if (useVariableName !== '' && !filters.value[useVariableName]) {
removePropertyFromFilter(filterName)
return
}
let found = false
params.value.filter_by.forEach((f, i) => {
if (f === filterName) {
found = true
params.value.filter_value[i] = filters.value[variableName]
}
})
if (!found) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push(comparator)
params.value.filter_value.push(filters.value[variableName])
}
change()
}
function prepareSingleValue(
/** The filter name in the api. */
filterName,
/** The name of the variable in filters ref. */
variableName = null,
/** The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null. */
useVariableName = null,
/** Toggles if the value should be parsed as a number. */
isNumber = false,
) {
if (variableName === null) {
variableName = filterName
}
let found = false
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName) {
found = i
break
}
}
if (found === false && useVariableName !== null) {
filters.value[useVariableName] = false
return
}
if (isNumber) {
filters.value[variableName] = Number(params.value.filter_value[found])
} else {
filters.value[variableName] = params.value.filter_value[found]
}
if (useVariableName !== null) {
filters.value[useVariableName] = true
}
}
function prepareDone() {
// Set filters.done based on params
if (typeof params.value.filter_by === 'undefined') {
return
}
filters.value.done = params.value.filter_by.some((f) => f === 'done') === false
}
async function prepareRelatedObjectFilter(kind: EntityType, filterName = null, servicePrefix: Omit<EntityType, 'labels'> | null = null) {
if (filterName === null) {
filterName = kind
}
if (servicePrefix === null) {
servicePrefix = kind
}
prepareSingleValue(filterName)
if (typeof filters.value[filterName] === 'undefined' || filters.value[filterName] === '') {
return
}
// Don't load things if we already have something loaded.
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if (entities[kind].length > 0) {
return
}
entities[kind] = await services[servicePrefix].getAll({}, {s: filters.value[filterName]})
}
async function prepareProjectsFilter() {
await prepareRelatedObjectFilter('projects', 'project_id')
entities.projects = entities.projects.filter(p => p.id > 0)
}
function setDoneFilter() {
if (filters.value.done) {
removePropertyFromFilter('done')
} else {
params.value.filter_by.push('done')
params.value.filter_comparator.push('equals')
params.value.filter_value.push('false')
}
change()
}
function setFilterConcat() {
if (filters.value.requireAllFilters) {
params.value.filter_concat = 'and'
} else {
params.value.filter_concat = 'or'
}
change()
}
function setPriority() {
setSingleValueFilter('priority', 'priority', 'usePriority')
}
function setPercentDoneFilter() {
setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
}
async function changeMultiselectFilter(kind: EntityType, filterName) {
await nextTick()
if (entities[kind].length === 0) {
removePropertyFromFilter(filterName)
change()
return
}
const ids = entities[kind].map(u => kind === 'users' ? u.username : u.id)
filters.value[filterName] = ids.join(',')
setSingleValueFilter(filterName, filterName, '', 'in')
}
function changeLabelFilter() {
if (entities.labels.length === 0) {
removePropertyFromFilter('labels')
change()
return
}
const labelIDs = entities.labels.map(u => u.id)
filters.value.labels = labelIDs.join(',')
setSingleValueFilter('labels', 'labels', '', 'in')
}
</script>
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
.fancycheckbox {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>

View File

@ -47,12 +47,6 @@
>
{{ $t('menu.edit') }}
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
icon="eye"
>
{{ $t('menu.views') }}
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
@ -96,6 +90,7 @@
{{ $t('project.webhooks.title') }}
</DropdownItem>
<DropdownItem
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
@ -134,6 +129,9 @@ const props = defineProps({
type: Object as PropType<IProject>,
required: true,
},
level: {
type: Number,
},
})
const projectStore = useProjectStore()

View File

@ -1,233 +0,0 @@
<script setup lang="ts">
import type {IProjectView} from '@/modelTypes/IProjectView'
import XButton from '@/components/input/button.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref, watch} from 'vue'
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
const {
modelValue,
} = defineProps<{
modelValue: IProjectView,
}>()
const emit = defineEmits(['update:modelValue'])
const view = ref<IProjectView>()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
watch(
() => modelValue,
newValue => {
const transformed = {
...newValue,
filter: transformFilterStringFromApi(
newValue.filter,
labelId => labelStore.getLabelById(labelId)?.title,
projectId => projectStore.projects[projectId]?.title || null,
),
}
if (JSON.stringify(view.value) !== JSON.stringify(transformed)) {
view.value = transformed
}
},
{immediate: true, deep: true},
)
watch(
() => view.value,
newView => {
emit('update:modelValue', {
...newView,
filter: transformFilterStringForApi(
newView.filter,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
),
})
},
{deep: true},
)
const titleValid = ref(true)
function validateTitle() {
titleValid.value = view.value?.title !== ''
}
</script>
<template>
<form>
<div class="field">
<label
class="label"
for="title"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
id="title"
v-model="view.title"
v-focus
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
@blur="validateTitle"
>
</div>
<p
v-if="!titleValid"
class="help is-danger"
>
{{ $t('project.views.titleRequired') }}
</p>
</div>
<div class="field">
<label
class="label"
for="kind"
>
{{ $t('project.views.kind') }}
</label>
<div class="control">
<div class="select">
<select
id="kind"
v-model="view.viewKind"
>
<option value="list">
{{ $t('project.list.title') }}
</option>
<option value="gantt">
{{ $t('project.gantt.title') }}
</option>
<option value="table">
{{ $t('project.table.title') }}
</option>
<option value="kanban">
{{ $t('project.kanban.title') }}
</option>
</select>
</div>
</div>
</div>
<FilterInput
v-model="view.filter"
:input-label="$t('project.views.filter')"
/>
<div
v-if="view.viewKind === 'kanban'"
class="field"
>
<label
class="label"
for="configMode"
>
{{ $t('project.views.bucketConfigMode') }}
</label>
<div class="control">
<div class="select">
<select
id="configMode"
v-model="view.bucketConfigurationMode"
>
<option value="manual">
{{ $t('project.views.bucketConfigManual') }}
</option>
<option value="filter">
{{ $t('project.views.filter') }}
</option>
</select>
</div>
</div>
</div>
<div
v-if="view.viewKind === 'kanban' && view.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">
{{ $t('project.views.bucketConfig') }}
</label>
<div class="control">
<div
v-for="(b, index) in view.bucketConfiguration"
:key="'bucket_'+index"
class="filter-bucket"
>
<button
class="is-danger"
@click.prevent="() => view.bucketConfiguration.splice(index, 1)"
>
<icon icon="trash-alt" />
</button>
<div class="filter-bucket-form">
<div class="field">
<label
class="label"
:for="'bucket_'+index+'_title'"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
:id="'bucket_'+index+'_title'"
v-model="view.bucketConfiguration[index].title"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
</div>
</div>
<FilterInput
v-model="view.bucketConfiguration[index].filter"
:input-label="$t('project.views.filter')"
/>
</div>
</div>
<div class="is-flex is-justify-content-end">
<XButton
variant="secondary"
icon="plus"
@click="() => view.bucketConfiguration.push({title: '', filter: ''})"
>
{{ $t('project.kanban.addBucket') }}
</XButton>
</div>
</div>
</div>
</form>
</template>
<style scoped lang="scss">
.filter-bucket {
display: flex;
button {
background: transparent;
border: none;
color: var(--danger);
padding-right: .75rem;
cursor: pointer;
}
&-form {
margin-bottom: .5rem;
padding: .5rem;
border: 1px solid var(--grey-200);
border-radius: $radius;
width: 100%;
}
}
</style>

View File

@ -85,7 +85,7 @@
</template>
<script setup lang="ts">
import {type ComponentPublicInstance, computed, ref, shallowReactive, watchEffect} from 'vue'
import {ref, computed, watchEffect, shallowReactive, type ComponentPublicInstance} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
@ -107,14 +107,13 @@ import {useTaskStore} from '@/stores/tasks'
import {useAuthStore} from '@/stores/auth'
import {getHistory} from '@/modules/projectHistory'
import {parseTaskText, PREFIXES, PrefixMode} from '@/modules/parseTaskText'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {IAbstract} from '@/modelTypes/IAbstract'
import {isSavedFilter} from '@/services/savedFilter'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
@ -281,13 +280,10 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => {
if (Object.keys(baseStore.currentProject).length === 0 || isSavedFilter(baseStore.currentProject)) {
return null
}
return baseStore.currentProject
})
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
? null
: baseStore.currentProject,
)
const hintText = computed(() => {
if (selectedCmd.value !== null && currentProject.value !== null) {
@ -354,6 +350,26 @@ const isNewTaskCommand = computed(() => (
const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
type Filter = { by: string, value: string | number, comparator: string }
function filtersToParams(filters: Filter[]) {
const filter_by: Filter['by'][] = []
const filter_value: Filter['value'][] = []
const filter_comparator: Filter['comparator'][] = []
filters.forEach(({by, value, comparator}) => {
filter_by.push(by)
filter_value.push(value)
filter_comparator.push(comparator)
})
return {
filter_by,
filter_value,
filter_comparator,
}
}
function searchTasks() {
if (
searchMode.value !== SEARCH_MODE.ALL &&
@ -375,27 +391,40 @@ function searchTasks() {
const {text, project: projectName, labels} = parsedQuery.value
let filter = ''
const filters: Filter[] = []
// FIXME: improve types
function addFilter(
by: Filter['by'],
value: Filter['value'],
comparator: Filter['comparator'],
) {
filters.push({
by,
value,
comparator,
})
}
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
console.log({project})
if (project !== null) {
filter += ' project = ' + project.id
addFilter('project_id', project.id, 'equals')
}
}
if (labels.length > 0) {
const labelIds = labelStore.getLabelsByExactTitles(labels).map((l) => l.id)
if (labelIds.length > 0) {
filter += 'labels in ' + labelIds.join(', ')
addFilter('labels', labelIds.join(), 'in')
}
}
const params = {
s: text,
sort_by: 'done',
filter,
...filtersToParams(filters),
}
taskSearchTimeout.value = setTimeout(async () => {

View File

@ -173,11 +173,11 @@
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(view) in availableViews"
:key="view.id"
:value="view.id"
v-for="(title, key) in availableViews"
:key="key"
:value="key"
>
{{ view.title }}
{{ title }}
</option>
</select>
</div>
@ -230,9 +230,9 @@ import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import type {IProjectView} from '@/modelTypes/IProjectView'
const props = defineProps({
projectId: {
@ -252,13 +252,17 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], IProjectView['id']>
type SelectedViewMapper = Record<IProject['id'], ProjectView>
const selectedView = ref<SelectedViewMapper>({})
const projectStore = useProjectStore()
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
}))
const availableViews = computed<IProjectView[]>(() => projectStore.projects[props.projectId]?.views || [])
const copy = useCopyToClipboard()
watch(
() => props.projectId,
@ -277,7 +281,7 @@ async function load(projectId: IProject['id']) {
const links = await linkShareService.getAll({projectId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = availableViews.value[0].id
selectedView.value[l.id] = 'list'
})
linkShares.value = links
}
@ -311,8 +315,8 @@ async function remove(projectId: IProject['id']) {
}
}
function getShareLink(hash: string, viewId: IProjectView['id']) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + viewId
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
}
</script>

View File

@ -16,22 +16,7 @@
:search-results="found"
:label="searchLabel"
@search="find"
>
<template #searchResult="{option: result}">
<User
v-if="shareType === 'user'"
:avatar-size="24"
:show-username="true"
:user="result"
/>
<span
v-else
class="search-result"
>
{{ result.name }}
</span>
</template>
</Multiselect>
/>
</p>
<p class="control">
<x-button @click="add()">
@ -187,8 +172,6 @@ import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import User from '@/components/misc/user.vue'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
@ -227,8 +210,8 @@ const selectedRight = ref({})
const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const configStore = useConfigStore()
const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
@ -377,15 +360,7 @@ async function find(query: string) {
found.value = []
return
}
// Include public teams here if we are sharing with teams and its enabled in the config
let results = []
if (props.shareType === 'team' && configStore.publicTeamsEnabled) {
results = await searchService.getAll({}, {s: query, includePublic: true})
} else {
results = await searchService.getAll({}, {s: query})
}
const results = await searchService.getAll({}, {s: query})
found.value = results
.filter(m => {
if(props.shareType === 'user' && m.id === currentUserId.value) {

View File

@ -21,12 +21,12 @@
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{date}">
<template #timeunit="{value, date}">
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ date.getDate() }}</span>
<span>{{ value }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>

View File

@ -323,8 +323,9 @@ async function setCoverImage(attachment: IAttachment | null) {
margin-bottom: 0;
display: flex;
> span,
> span:not(:last-child):after,
> button:not(:last-child):after {
content: '·';
padding: 0 .25rem;
}
}

View File

@ -97,13 +97,6 @@
editComment()
}"
/>
<Reactions
v-model="c.reactions"
class="mt-2"
entity-kind="comments"
:entity-id="c.id"
:disabled="!canWrite"
/>
</div>
</div>
<div
@ -197,7 +190,6 @@ import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue'
const props = defineProps({
taskId: {

View File

@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watch, onBeforeUnmount} from 'vue'
import {ref, computed, watch} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
@ -88,12 +88,6 @@ async function saveWithDelay() {
}, 5000)
}
onBeforeUnmount(() => {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
})
async function save() {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)

View File

@ -47,6 +47,7 @@
<ReminderPeriod
v-if="activeForm === 'relative'"
v-model="reminder"
@update:modelValue="updateDataAndMaybeClose(close)"
/>
<DatepickerInline
@ -59,7 +60,7 @@
v-if="showFormSwitch !== null"
class="reminder__close-button"
:shadow="false"
@click="updateDataAndMaybeCloseNow(close)"
@click="updateDataAndMaybeClose(close)"
>
{{ $t('misc.confirm') }}
</x-button>
@ -72,10 +73,10 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {SECONDS_A_DAY, SECONDS_A_HOUR} from '@/constants/date'
import {type IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {useI18n} from 'vue-i18n'
import {type PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import {PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {formatDateShort} from '@/helpers/time/formatDate'
@ -86,7 +87,6 @@ import Popup from '@/components/misc/popup.vue'
import TaskReminderModel from '@/models/taskReminder'
import Card from '@/components/misc/card.vue'
import SimpleButton from '@/components/input/SimpleButton.vue'
import {useDebounceFn} from '@vueuse/core'
const {
modelValue,
@ -112,7 +112,7 @@ const presets = computed<TaskReminderModel[]>(() => [
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 7, relativeTo: defaultRelativeTo},
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 30, relativeTo: defaultRelativeTo},
])
const reminderDate = ref<Date | null>(null)
const reminderDate = ref<Date|null>(null)
type availableForms = null | 'relative' | 'absolute'
@ -142,16 +142,16 @@ const reminderText = computed(() => {
watch(
() => modelValue,
(newReminder) => {
if (newReminder) {
if(newReminder) {
reminder.value = newReminder
if (newReminder.relativeTo === null) {
if(newReminder.relativeTo === null) {
reminderDate.value = new Date(newReminder.reminder)
}
return
}
reminder.value = new TaskReminderModel()
},
{immediate: true},
@ -181,9 +181,7 @@ function setReminderFromPreset(preset, close) {
close()
}
const updateDataAndMaybeClose = useDebounceFn(updateDataAndMaybeCloseNow, 500)
function updateDataAndMaybeCloseNow(close) {
function updateDataAndMaybeClose(close) {
updateData()
if (clearAfterUpdate) {
close()

View File

@ -71,7 +71,8 @@ import {periodToSeconds, PeriodUnit, secondsToPeriod} from '@/helpers/time/perio
import TaskReminderModel from '@/models/taskReminder'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {type IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES, type IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
import {useDebounceFn} from '@vueuse/core'
const {
modelValue,
@ -122,7 +123,7 @@ function updateData() {
reminder.value.relativeTo = period.value.relativeTo
reminder.value.reminder = null
emit('update:modelValue', reminder.value)
useDebounceFn(() => emit('update:modelValue', reminder.value), 1000)
}
</script>

View File

@ -30,7 +30,7 @@
<router-link
v-if="showProject && typeof project !== 'undefined'"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project mr-1"
:class="{'mr-2': task.hexColor !== ''}"
>
@ -111,7 +111,7 @@
<icon icon="align-left" />
</span>
<span
v-if="task.repeatAfter.amount > 0 || (task.repeatAfter.amount === 0 && task.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_MONTH)"
v-if="task.repeatAfter.amount > 0"
class="project-task-icon"
>
<icon icon="history" />
@ -136,7 +136,7 @@
<router-link
v-if="showProjectSeparately"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project"
>
{{ project.title }}
@ -207,7 +207,6 @@ import {useIntervalFn} from '@vueuse/core'
import {playPopSound} from '@/helpers/playPop'
import {useAuthStore} from '@/stores/auth'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
const {
theTask,

View File

@ -20,7 +20,7 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
textareaEl.value = textareaEl.placeholder
}
// const cs = getComputedStyle(textareaEl)
const cs = getComputedStyle(textareaEl)
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'

View File

@ -1,14 +1,12 @@
import {computed, h, shallowRef, type VNode, watchEffect} from 'vue'
import {computed, shallowRef, watchEffect, h, type VNode} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const routeWithModal = computed(() => {
return backdropView.value
@ -31,7 +29,7 @@ export function useRouteWithModal() {
if (routePropsOption === true) {
routeProps = route.params
} else {
if (typeof routePropsOption === 'function') {
if(typeof routePropsOption === 'function') {
routeProps = routePropsOption(route)
} else {
routeProps = routePropsOption
@ -54,7 +52,7 @@ export function useRouteWithModal() {
}
currentModal.value = h(component, routeProps)
})
const historyState = computed(() => route.fullPath && window.history.state)
function closeModal() {
@ -62,23 +60,12 @@ export function useRouteWithModal() {
// If the current project was changed because the user moved the currently opened task while coming from kanban,
// we need to reflect that change in the route when they close the task modal.
// The last route is only available as resolved string, therefore we need to use a regex for matching here
const routeMatch = new RegExp('\\/projects\\/\\d+\\/(\\d+)', 'g')
const match = routeMatch.exec(historyState.value.back)
if (match !== null && baseStore.currentProject) {
let viewId: string | number = match[1]
if (!viewId) {
viewId = projectStore.projects[baseStore.currentProject?.id].views[0]?.id
}
const newRoute = {
name: 'project.view',
params: {
projectId: baseStore.currentProject?.id,
viewId,
},
}
router.push(newRoute)
const kanbanRouteMatch = new RegExp('\\/projects\\/\\d+\\/kanban', 'g')
const kanbanRouter = {name: 'project.kanban', params: {projectId: baseStore.currentProject?.id}}
if (kanbanRouteMatch.test(historyState.value.back)
&& baseStore.currentProject
&& historyState.value.back !== router.resolve(kanbanRouter).fullPath) {
router.push(kanbanRouter)
return
}

View File

@ -1,13 +1,11 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useRoute} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
import TaskCollectionService from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -26,6 +24,16 @@ export interface SortBy {
done_at?: Order,
}
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
})
const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
@ -55,17 +63,13 @@ const SORT_BY_DEFAULT: SortBy = {
/**
* This mixin provides a base set of methods and properties to get tasks.
*/
export function useTaskList(
projectIdGetter: ComputedGetter<IProject['id']>,
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
sortByDefault: SortBy = SORT_BY_DEFAULT,
) {
export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sortByDefault: SortBy = SORT_BY_DEFAULT) {
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const params = ref({...getDefaultParams()})
const search = ref('')
const page = useRouteQuery('page', '1', { transform: Number })
const sortBy = ref({ ...sortByDefault })
@ -73,6 +77,10 @@ export function useTaskList(
const allParams = computed(() => {
const loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
return formatSortOrder(sortBy.value, loadParams)
})
@ -83,19 +91,11 @@ export function useTaskList(
page.value = 1
},
)
const authStore = useAuthStore()
const getAllTasksParams = computed(() => {
return [
{
projectId: projectId.value,
viewId: projectViewId.value,
},
{
...allParams.value,
filter_timezone: authStore.settings.timezone,
},
{projectId: projectId.value},
allParams.value,
page.value,
]
})
@ -117,38 +117,16 @@ export function useTaskList(
const route = useRoute()
watch(() => route.query, (query) => {
const {
page: pageQueryValue,
s,
filter,
} = query
if (s !== undefined) {
params.value.s = s as string
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
}
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
}
if (filter !== undefined) {
params.value.filter = filter
}
}, { immediate: true })
const router = useRouter()
watch(
() => [page.value, params.value.filter, params.value.s],
() => {
router.replace({
name: route.name,
params: route.params,
query: {
page: page.value,
filter: params.value.filter || undefined,
s: params.value.s || undefined,
},
})
},
{ deep: true },
)
// Only listen for query path changes
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
@ -165,6 +143,7 @@ export function useTaskList(
totalPages,
currentPage: page,
loadTasks,
searchTerm: search,
params,
sortByParam: sortBy,
}

View File

@ -1,222 +0,0 @@
import {describe, expect, it} from 'vitest'
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
const nullTitleToIdResolver = (title: string) => null
const nullIdToTitleResolver = (id: number) => null
describe('Filter Transformation', () => {
const fieldCases = {
'done': 'done',
'priority': 'priority',
'percentDone': 'percent_done',
'dueDate': 'due_date',
'startDate': 'start_date',
'endDate': 'end_date',
'doneAt': 'done_at',
'reminders': 'reminders',
'assignees': 'assignees',
'labels': 'labels',
}
describe('For API', () => {
for (const c in fieldCases) {
it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(fieldCases[c] + ' = ipsum')
})
}
it('should correctly resolve labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem',
(title: string) => 1,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels = 1')
})
const multipleDummyResolver = (title: string) => {
switch (title) {
case 'lorem':
return 1
case 'ipsum':
return 2
default:
return null
}
}
it('should correctly resolve multiple labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem && dueDate = now && labels = ipsum',
multipleDummyResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels = 1 && due_date = now && labels = 2')
})
it('should correctly resolve multiple labels with an in clause', () => {
const transformed = transformFilterStringForApi(
'labels in lorem, ipsum && dueDate = now',
multipleDummyResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels in 1, 2 && due_date = now')
})
it('should correctly resolve projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem',
nullTitleToIdResolver,
(title: string) => 1,
)
expect(transformed).toBe('project = 1')
})
it('should correctly resolve multiple projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem && dueDate = now || project = ipsum',
nullTitleToIdResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project = 1 && due_date = now || project = 2')
})
it('should correctly resolve multiple projects with in', () => {
const transformed = transformFilterStringForApi(
'project in lorem, ipsum',
nullTitleToIdResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project in 1, 2')
})
it('should resolve projects at the correct position', () => {
const transformed = transformFilterStringForApi(
'project = pr',
nullTitleToIdResolver,
(title: string) => 1,
)
expect(transformed).toBe('project = 1')
})
it('should resolve project and labels independently', () => {
const transformed = transformFilterStringForApi(
'project = lorem && labels = ipsum',
multipleDummyResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project = 1 && labels = 2')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringForApi(
'dueDate = now/d || dueDate > now/w+1w',
nullTitleToIdResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('due_date = now/d || due_date > now/w+1w')
})
})
describe('To API', () => {
for (const c in fieldCases) {
it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(c + ' = ipsum')
})
}
it('should correctly resolve labels', () => {
const transformed = transformFilterStringFromApi(
'labels = 1',
(id: number) => 'lorem',
nullIdToTitleResolver,
)
expect(transformed).toBe('labels = lorem')
})
const multipleIdToTitleResolver = (id: number) => {
switch (id) {
case 1:
return 'lorem'
case 2:
return 'ipsum'
default:
return null
}
}
it('should correctly resolve multiple labels', () => {
const transformed = transformFilterStringFromApi(
'labels = 1 && due_date = now && labels = 2',
multipleIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('labels = lorem && dueDate = now && labels = ipsum')
})
it('should correctly resolve multiple labels in', () => {
const transformed = transformFilterStringFromApi(
'labels in 1, 2',
multipleIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('labels in lorem, ipsum')
})
it('should correctly resolve projects', () => {
const transformed = transformFilterStringFromApi(
'project = 1',
nullIdToTitleResolver,
(id: number) => 'lorem',
)
expect(transformed).toBe('project = lorem')
})
it('should correctly resolve multiple projects', () => {
const transformed = transformFilterStringFromApi(
'project = 1 && due_date = now || project = 2',
nullIdToTitleResolver,
multipleIdToTitleResolver,
)
expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum')
})
it('should correctly resolve multiple projects in', () => {
const transformed = transformFilterStringFromApi(
'project in 1, 2',
nullIdToTitleResolver,
multipleIdToTitleResolver,
)
expect(transformed).toBe('project in lorem, ipsum')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringFromApi(
'due_date = now/d || due_date > now/w+1w',
nullIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('dueDate = now/d || dueDate > now/w+1w')
})
})
})

View File

@ -1,200 +0,0 @@
import {snakeCase} from 'snake-case'
export const DATE_FIELDS = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
export const ASSIGNEE_FIELDS = [
'assignees',
]
export const LABEL_FIELDS = [
'labels',
]
export const PROJECT_FIELDS = [
'project',
]
export const AUTOCOMPLETE_FIELDS = [
...LABEL_FIELDS,
...ASSIGNEE_FIELDS,
...PROJECT_FIELDS,
]
export const AVAILABLE_FILTER_FIELDS = [
...DATE_FIELDS,
...ASSIGNEE_FIELDS,
...LABEL_FIELDS,
...PROJECT_FIELDS,
'done',
'priority',
'percentDone',
]
export const FILTER_OPERATORS = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
export const FILTER_JOIN_OPERATOR = [
'&&',
'||',
'(',
')',
]
export const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=|in)'
export function getFilterFieldRegexPattern(field: string): RegExp {
return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()<]+\\1?)?', 'ig')
}
export function transformFilterStringForApi(
filter: string,
labelResolver: (title: string) => number | null,
projectResolver: (title: string) => number | null,
): string {
if (filter.trim() === '') {
return ''
}
// Transform labels to ids
LABEL_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const labelId = labelResolver(k)
if (labelId !== null) {
filter = filter.replace(k, String(labelId))
}
})
}
}
})
// Transform projects to ids
PROJECT_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
let replaced = keyword
keywords.forEach(k => {
const projectId = projectResolver(k)
if (projectId !== null) {
replaced = replaced.replace(k, String(projectId))
}
})
const actualKeywordStart = (match?.index || 0) + prefix.length
filter = filter.substring(0, actualKeywordStart) +
replaced +
filter.substring(actualKeywordStart + keyword.length)
}
}
})
// Transform all attributes to snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replaceAll(f, snakeCase(f))
})
return filter
}
export function transformFilterStringFromApi(
filter: string,
labelResolver: (id: number) => string | null,
projectResolver: (id: number) => string | null,
): string {
if (filter.trim() === '') {
return ''
}
// Transform all attributes from snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replaceAll(snakeCase(f), f)
})
// Transform labels to their titles
LABEL_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const labelTitle = labelResolver(parseInt(k))
if (labelTitle !== null) {
filter = filter.replace(k, labelTitle)
}
})
}
}
})
// Transform projects to ids
PROJECT_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const project = projectResolver(parseInt(k))
if (project !== null) {
filter = filter.replace(k, project)
}
})
}
}
})
return filter
}

View File

@ -1,17 +1,64 @@
import type { RouteRecordName } from 'vue-router'
import router from '@/router'
import type {IProject} from '@/modelTypes/IProject'
export type ProjectViewSettings = Record<IProject['id'], number>
export type ProjectRouteName = Extract<RouteRecordName, string>
export type ProjectViewSettings = Record<
IProject['id'],
Extract<RouteRecordName, ProjectRouteName>
>
const SETTINGS_KEY_PROJECT_VIEW = 'projectView'
// TODO: remove migration when releasing 1.0
type ListViewSettings = ProjectViewSettings
const SETTINGS_KEY_DEPRECATED_LIST_VIEW = 'listView'
function migrateStoredProjectRouteSettings() {
try {
const listViewSettingsString = localStorage.getItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
if (listViewSettingsString === null) {
return
}
// A) the first version stored one setting for all lists in a string
if (listViewSettingsString.startsWith('list.')) {
const projectView = listViewSettingsString.replace('list.', 'project.')
if (!router.hasRoute(projectView)) {
return
}
return projectView as RouteRecordName
}
// B) the last version used a 'list.' prefix
const listViewSettings: ListViewSettings = JSON.parse(listViewSettingsString)
const projectViewSettingEntries = Object.entries(listViewSettings).map(([id, value]) => {
return [id, value.replace('list.', 'project.')]
})
const projectViewSettings = Object.fromEntries(projectViewSettingEntries)
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
} catch(e) {
//
} finally {
localStorage.removeItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
}
}
/**
* Save the current project view to local storage
*/
export function saveProjectView(projectId: IProject['id'], viewId: number) {
if (!projectId || !viewId) {
export function saveProjectView(projectId: IProject['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!projectId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedProjectView = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
let savedProjectViewSettings: ProjectViewSettings | false = false
@ -24,19 +71,30 @@ export function saveProjectView(projectId: IProject['id'], viewId: number) {
projectViewSettings = savedProjectViewSettings
}
projectViewSettings[projectId] = viewId
projectViewSettings[projectId] = routeName
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
}
export function getProjectViewId(projectId: IProject['id']): number {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
return 0
export const getProjectView = (projectId: IProject['id']) => {
// TODO: remove migration when releasing 1.0
const migratedProjectView = migrateStoredProjectRouteSettings()
if (migratedProjectView !== undefined && router.hasRoute(migratedProjectView)) {
return migratedProjectView
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (isNaN(projectViewSettings[projectId])) {
return 0
}
return projectViewSettings[projectId]
try {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
throw new Error()
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (!router.hasRoute(projectViewSettings[projectId])) {
throw new Error()
}
return projectViewSettings[projectId]
} catch (e) {
return
}
}

View File

@ -2,7 +2,7 @@ import {calculateDayInterval} from './calculateDayInterval'
import {calculateNearestHours} from './calculateNearestHours'
import {replaceAll} from '../replaceAll'
export interface dateParseResult {
interface dateParseResult {
newText: string,
date: Date | null,
}
@ -12,7 +12,7 @@ interface dateFoundResult {
date: Date | null,
}
const monthsRegexGroup = '(january|february|march|april|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)'
const monthsRegexGroup = '(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)'
function matchesDateExpr(text: string, dateExpr: string): boolean {
return text.match(new RegExp('(^| )' + dateExpr, 'gi')) !== null
@ -60,12 +60,12 @@ export const parseDate = (text: string, now: Date = new Date()): dateParseResult
return addTimeToDate(text, date, 'end of month')
}
let parsed = getDateFromWeekday(text, now)
let parsed = getDateFromWeekday(text)
if (parsed.date !== null) {
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDayFromText(text, now)
parsed = getDayFromText(text)
if (parsed.date !== null) {
const month = getMonthFromText(text, parsed.date)
return addTimeToDate(month.newText, month.date, parsed.foundText)
@ -76,7 +76,7 @@ export const parseDate = (text: string, now: Date = new Date()): dateParseResult
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDateFromText(text, now)
parsed = getDateFromText(text)
if (parsed.date === null) {
return {
@ -230,7 +230,7 @@ export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
}
}
const getDateFromWeekday = (text: string, date: Date = new Date()): dateFoundResult => {
const getDateFromWeekday = (text: string): dateFoundResult => {
const matcher = /(^| )(next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const results: string[] | null = matcher.exec(text.toLowerCase()) // The i modifier does not seem to work.
if (results === null) {
@ -240,6 +240,7 @@ const getDateFromWeekday = (text: string, date: Date = new Date()): dateFoundRes
}
}
const date: Date = new Date()
const currentDay: number = date.getDay()
let day = 0
@ -295,7 +296,7 @@ const getDateFromWeekday = (text: string, date: Date = new Date()): dateFoundRes
}
}
const getDayFromText = (text: string, now: Date = new Date()) => {
const getDayFromText = (text: string) => {
const matcher = /(^| )(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)($| )/ig
const results = matcher.exec(text)
if (results === null) {
@ -305,6 +306,7 @@ const getDayFromText = (text: string, now: Date = new Date()) => {
}
}
const now = new Date()
const date = new Date(now)
const day = parseInt(results[0])
date.setDate(day)

View File

@ -19,16 +19,9 @@ export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: nu
}
}
if (seconds % SECONDS_A_HOUR === 0) {
return {
unit: 'hours',
amount: seconds / SECONDS_A_HOUR,
}
}
return {
unit: 'minutes',
amount: seconds / SECONDS_A_MINUTE,
unit: 'hours',
amount: seconds / SECONDS_A_HOUR,
}
}

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