forked from vikunja/vikunja
Compare commits
72 Commits
01fb80d7a1
...
a00473ae2a
Author | SHA1 | Date |
---|---|---|
Daniel Herrmann | a00473ae2a | |
Elscrux | 4b4a7f3c0a | |
waza-ari | ffa82556e0 | |
renovate | d7fdefcead | |
Elscrux | 1d5517b53a | |
kolaente | 25742385ba | |
renovate | 2fa576d9f5 | |
Frederick [Bot] | 116b909d31 | |
konrad | e95159a33c | |
renovate | b340bb418b | |
kolaente | 009e9b5455 | |
kolaente | 654e95d99f | |
kolaente | d628471d0e | |
kolaente | 4e6e0608c7 | |
kolaente | 6fea5640e8 | |
kolaente | b874b02412 | |
kolaente | 084a62e835 | |
kolaente | f3e2b1b89b | |
kolaente | 4e26fa0b85 | |
kolaente | 32e1a2018a | |
kolaente | 05d3bb4fb6 | |
kolaente | 38985a8318 | |
kolaente | d0b762d761 | |
kolaente | e0a7f46e5d | |
kolaente | be253333c2 | |
kolaente | 533e778b93 | |
kolaente | 1d2f3ca546 | |
kolaente | 55b806d311 | |
kolaente | 0c947790e8 | |
kolaente | b35eb4adbf | |
kolaente | a22652b737 | |
kolaente | 4dcd3abe9e | |
kolaente | 5a13c2b423 | |
kolaente | 9eac746984 | |
kolaente | b1d9dc6fc3 | |
kolaente | 8fa2f6686a | |
kolaente | 9ade917ac4 | |
kolaente | 7fc1f27ef5 | |
kolaente | 356399f853 | |
kolaente | 9ed93b181d | |
kolaente | 981f2d0e70 | |
kolaente | 2990c01d0a | |
kolaente | 2daecbc2bc | |
kolaente | 35487093c6 | |
kolaente | 571bcf8996 | |
kolaente | 388a3a68ba | |
kolaente | 992d108bfa | |
kolaente | c22daab28c | |
kolaente | 3bd639a110 | |
kolaente | 0d12d72b73 | |
kolaente | 1827102a0a | |
kolaente | 4586e525ce | |
kolaente | c162a5a457 | |
kolaente | b978d344ca | |
kolaente | 28fa2c517a | |
kolaente | bc6d812eb0 | |
kolaente | 87c027aafd | |
kolaente | 65e1357705 | |
kolaente | eebfee73d3 | |
kolaente | ef1cc9720c | |
kolaente | c6b682507a | |
kolaente | 9d3fb6f81d | |
kolaente | 3ea81db836 | |
kolaente | 76ed2cff5f | |
kolaente | e43349618b | |
kolaente | 9624cc9e97 | |
kolaente | 764bc15d49 | |
kolaente | 3fc4aaa2a1 | |
kolaente | 9f73e2c5f9 | |
kolaente | c1e137d8ee | |
kolaente | de320aac72 | |
kolaente | 307ffe11c4 |
22
.drone.yml
22
.drone.yml
|
@ -139,7 +139,7 @@ steps:
|
|||
event: [ push, tag, pull_request ]
|
||||
|
||||
- name: api-lint
|
||||
image: golangci/golangci-lint:v1.55.2
|
||||
image: golangci/golangci-lint:v1.56.2
|
||||
pull: always
|
||||
environment:
|
||||
GOPROXY: 'https://goproxy.kolaente.de'
|
||||
|
@ -364,7 +364,7 @@ steps:
|
|||
- api-build
|
||||
|
||||
- name: frontend-dependencies
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -378,7 +378,7 @@ steps:
|
|||
# - restore-cache
|
||||
|
||||
- name: frontend-lint
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -390,7 +390,7 @@ steps:
|
|||
- frontend-dependencies
|
||||
|
||||
- name: frontend-build-prod
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -402,7 +402,7 @@ steps:
|
|||
- frontend-dependencies
|
||||
|
||||
- name: frontend-test-unit
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
commands:
|
||||
- cd frontend
|
||||
|
@ -413,7 +413,7 @@ steps:
|
|||
|
||||
- name: frontend-typecheck
|
||||
failure: ignore
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -545,7 +545,7 @@ steps:
|
|||
- git fetch --tags
|
||||
|
||||
- name: frontend-dependencies
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -557,7 +557,7 @@ steps:
|
|||
- pnpm install --fetch-timeout 100000
|
||||
|
||||
- name: frontend-build
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -902,7 +902,7 @@ steps:
|
|||
- git fetch --tags
|
||||
|
||||
- name: build
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -963,7 +963,7 @@ steps:
|
|||
- git fetch --tags
|
||||
|
||||
- name: build
|
||||
image: node:20.11.0-alpine
|
||||
image: node:20.11.1-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -1401,6 +1401,6 @@ steps:
|
|||
- failure
|
||||
---
|
||||
kind: signature
|
||||
hmac: a569410ea13ad83c15c7606ed44b17b6bac0eb66d668344dfbf008c9448b4af5
|
||||
hmac: a5d31a6cb5eb6482e72bea619ee391ff2b8118b9865e3896a607b8a7e874a797
|
||||
|
||||
...
|
||||
|
|
|
@ -99,3 +99,7 @@ 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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS frontendbuilder
|
||||
FROM --platform=$BUILDPLATFORM node:20.11.1-alpine AS frontendbuilder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
|
@ -62,6 +62,9 @@ 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
|
||||
|
|
|
@ -346,6 +346,17 @@ Full path: `service.customlogourl`
|
|||
Environment path: `VIKUNJA_SERVICE_CUSTOMLOGOURL`
|
||||
|
||||
|
||||
### enablepublicteams
|
||||
|
||||
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
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
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.
|
|
@ -64,7 +64,7 @@ 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> <----- Used for OIDC Discovery, usually the issuer
|
||||
clientid: <vikunja client-id>
|
||||
clientsecret: <vikunja client-secret>
|
||||
scope: openid profile email
|
||||
|
@ -99,7 +99,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 claim structure expexted by Vikunja is as follows:
|
||||
The minimal claim structure expected by Vikunja is as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -116,6 +116,21 @@ The claim structure expexted 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.
|
||||
|
||||
|
|
|
@ -73,29 +73,30 @@ This document describes the different errors Vikunja can return.
|
|||
|
||||
| ErrorCode | HTTP Status Code | Description |
|
||||
|-----------|------------------|----------------------------------------------------------------------------|
|
||||
| 4001 | 400 | The project task text cannot be empty. |
|
||||
| 4002 | 404 | The project task does not exist. |
|
||||
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
|
||||
| 4004 | 403 | Need at least one task when bulk editing tasks. |
|
||||
| 4005 | 403 | The user does not have the right to see the task. |
|
||||
| 4006 | 403 | The user tried to set a parent task as the task itself. |
|
||||
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
|
||||
| 4008 | 409 | The user tried to create a task relation which already exists. |
|
||||
| 4009 | 404 | The task relation does not exist. |
|
||||
| 4010 | 400 | Cannot relate a task with itself. |
|
||||
| 4011 | 404 | The task attachment does not exist. |
|
||||
| 4012 | 400 | The task attachment is too large. |
|
||||
| 4013 | 400 | The task sort param is invalid. |
|
||||
| 4014 | 400 | The task sort order is invalid. |
|
||||
| 4015 | 404 | The task comment does not exist. |
|
||||
| 4016 | 400 | Invalid task field. |
|
||||
| 4017 | 400 | Invalid task filter comparator. |
|
||||
| 4018 | 400 | Invalid task filter concatinator. |
|
||||
| 4019 | 400 | Invalid task filter value. |
|
||||
| 4020 | 400 | The provided attachment does not belong to that task. |
|
||||
| 4021 | 400 | This user is already assigned to that task. |
|
||||
| 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. |
|
||||
| 4001 | 400 | The project task text cannot be empty. |
|
||||
| 4002 | 404 | The project task does not exist. |
|
||||
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
|
||||
| 4004 | 403 | Need at least one task when bulk editing tasks. |
|
||||
| 4005 | 403 | The user does not have the right to see the task. |
|
||||
| 4006 | 403 | The user tried to set a parent task as the task itself. |
|
||||
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
|
||||
| 4008 | 409 | The user tried to create a task relation which already exists. |
|
||||
| 4009 | 404 | The task relation does not exist. |
|
||||
| 4010 | 400 | Cannot relate a task with itself. |
|
||||
| 4011 | 404 | The task attachment does not exist. |
|
||||
| 4012 | 400 | The task attachment is too large. |
|
||||
| 4013 | 400 | The task sort param is invalid. |
|
||||
| 4014 | 400 | The task sort order is invalid. |
|
||||
| 4015 | 404 | The task comment does not exist. |
|
||||
| 4016 | 400 | Invalid task field. |
|
||||
| 4017 | 400 | Invalid task filter comparator. |
|
||||
| 4018 | 400 | Invalid task filter concatinator. |
|
||||
| 4019 | 400 | Invalid task filter value. |
|
||||
| 4020 | 400 | The provided attachment does not belong to that task. |
|
||||
| 4021 | 400 | This user is already assigned to that task. |
|
||||
| 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. |
|
||||
|
||||
## Team
|
||||
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
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 list
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -1 +1 @@
|
|||
20.11.0
|
||||
20.11.1
|
|
@ -160,7 +160,7 @@
|
|||
"esbuild": "0.20.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-vue": "9.22.0",
|
||||
"happy-dom": "13.7.0",
|
||||
"happy-dom": "13.7.1",
|
||||
"histoire": "0.17.9",
|
||||
"postcss": "8.4.35",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
|
|
|
@ -334,8 +334,8 @@ devDependencies:
|
|||
specifier: 9.22.0
|
||||
version: 9.22.0(eslint@8.57.0)
|
||||
happy-dom:
|
||||
specifier: 13.7.0
|
||||
version: 13.7.0
|
||||
specifier: 13.7.1
|
||||
version: 13.7.1
|
||||
histoire:
|
||||
specifier: 0.17.9
|
||||
version: 0.17.9(@types/node@20.11.25)(sass@1.71.1)(terser@5.24.0)(vite@5.1.5)
|
||||
|
@ -386,7 +386,7 @@ devDependencies:
|
|||
version: 5.1.0(vue@3.4.21)
|
||||
vitest:
|
||||
specifier: 1.3.1
|
||||
version: 1.3.1(@types/node@20.11.25)(happy-dom@13.7.0)(sass@1.71.1)(terser@5.24.0)
|
||||
version: 1.3.1(@types/node@20.11.25)(happy-dom@13.7.1)(sass@1.71.1)(terser@5.24.0)
|
||||
vue-tsc:
|
||||
specifier: 2.0.6
|
||||
version: 2.0.6(typescript@5.4.2)
|
||||
|
@ -6449,8 +6449,8 @@ packages:
|
|||
strip-bom-string: 1.0.0
|
||||
dev: true
|
||||
|
||||
/happy-dom@13.7.0:
|
||||
resolution: {integrity: sha512-E02c+vzhvZvmyh60GjUi8zZF7HMKBe+ZD5LfIOu6AAbwkI2IU3vBh897joHaGFuQLG8/griTk0X7J/QgHV5WuQ==}
|
||||
/happy-dom@13.7.1:
|
||||
resolution: {integrity: sha512-uQgxSTqQY4lMVIhV/W6GWYOT6h7Z6CNlsa+SyvAcOy311spU3zPDNAMzayJky9q4xqfEQf3cQj8yDZngiYUEDA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
dependencies:
|
||||
entities: 4.5.0
|
||||
|
@ -10079,7 +10079,7 @@ packages:
|
|||
fsevents: 2.3.3
|
||||
dev: true
|
||||
|
||||
/vitest@1.3.1(@types/node@20.11.25)(happy-dom@13.7.0)(sass@1.71.1)(terser@5.24.0):
|
||||
/vitest@1.3.1(@types/node@20.11.25)(happy-dom@13.7.1)(sass@1.71.1)(terser@5.24.0):
|
||||
resolution: {integrity: sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
@ -10114,7 +10114,7 @@ packages:
|
|||
chai: 4.3.10
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
execa: 8.0.1
|
||||
happy-dom: 13.7.0
|
||||
happy-dom: 13.7.1
|
||||
local-pkg: 0.5.0
|
||||
magic-string: 0.30.7
|
||||
pathe: 1.1.1
|
||||
|
|
|
@ -19,3 +19,28 @@ export const DATE_RANGES = {
|
|||
'thisYear': ['now/y', 'now/y+1y'],
|
||||
'restOfThisYear': ['now', 'now/y+1y'],
|
||||
}
|
||||
|
||||
export const DATE_VALUES = {
|
||||
'now': 'now',
|
||||
'startOfToday': 'now/d',
|
||||
'endOfToday': 'now/d+1d',
|
||||
|
||||
'beginningOflastWeek': 'now/w-1w',
|
||||
'endOfLastWeek': 'now/w-2w',
|
||||
'beginningOfThisWeek': 'now/w',
|
||||
'endOfThisWeek': 'now/w+1w',
|
||||
'startOfNextWeek': 'now/w+1w',
|
||||
'endOfNextWeek': 'now/w+2w',
|
||||
'in7Days': 'now+7d',
|
||||
|
||||
'beginningOfLastMonth': 'now/M-1M',
|
||||
'endOfLastMonth': 'now/M-2M',
|
||||
'startOfThisMonth': 'now/M',
|
||||
'endOfThisMonth': 'now/M+1M',
|
||||
'startOfNextMonth': 'now/M+1M',
|
||||
'endOfNextMonth': 'now/M+2M',
|
||||
'in30Days': 'now+30d',
|
||||
|
||||
'startOfThisYear': 'now/y',
|
||||
'endOfThisYear': 'now/y+1y',
|
||||
}
|
||||
|
|
|
@ -75,14 +75,15 @@
|
|||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
|
@ -111,7 +112,7 @@ import Popup from '@/components/misc/popup.vue'
|
|||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
<template>
|
||||
<div class="datepicker-with-range-container">
|
||||
<Popup
|
||||
:open="open"
|
||||
@close="() => emit('close')"
|
||||
>
|
||||
<template #content="{isOpen}">
|
||||
<div
|
||||
class="datepicker-with-range"
|
||||
:class="{'is-open': isOpen}"
|
||||
>
|
||||
<div class="selections">
|
||||
<BaseButton
|
||||
:class="{'is-active': customRangeActive}"
|
||||
@click="setDate(null)"
|
||||
>
|
||||
{{ $t('misc.custom') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-for="(value, text) in DATE_VALUES"
|
||||
:key="text"
|
||||
:class="{'is-active': date === value}"
|
||||
@click="setDate(value)"
|
||||
>
|
||||
{{ $t(`input.datepickerRange.values.${text}`) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="flatpickr-container input-group">
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.date') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="date"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<flat-pickr
|
||||
v-model="flatpickrDate"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
</p>
|
||||
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
>
|
||||
<DatemathHelp />
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_VALUES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: false,
|
||||
wrap: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
|
||||
const showHowItWorks = ref(false)
|
||||
|
||||
const flatpickrDate = ref('')
|
||||
|
||||
const date = ref<string|Date>('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
date.value = newValue
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const parsed = parseDateOrString(date.value, false)
|
||||
if (parsed instanceof Date) {
|
||||
flatpickrDate.value = date.value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function emitChanged() {
|
||||
emit('update:modelValue', date.value === '' ? null : date.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => flatpickrDate.value,
|
||||
(newVal: string | null) => {
|
||||
if (newVal === null) {
|
||||
return
|
||||
}
|
||||
|
||||
date.value = newVal
|
||||
|
||||
emitChanged()
|
||||
},
|
||||
)
|
||||
watch(() => date.value, emitChanged)
|
||||
|
||||
function setDate(range: string | null) {
|
||||
if (range === null) {
|
||||
date.value = ''
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
date.value = range
|
||||
}
|
||||
|
||||
const customRangeActive = computed<boolean>(() => {
|
||||
return !Object.values(DATE_VALUES).some(d => date.value === d)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker-with-range-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
z-index: 10;
|
||||
margin-top: 1rem;
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
background-color: var(--white);
|
||||
box-shadow: $shadow;
|
||||
|
||||
&.is-open {
|
||||
width: 500px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-with-range {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
width: 70%;
|
||||
border-left: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
font-size: .9rem;
|
||||
|
||||
// Flatpickr has no option to use it without an input field so we're hiding it instead
|
||||
:deep(input.form-control.input) {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.field .control :deep(.button) {
|
||||
border: 1px solid var(--input-border-color);
|
||||
height: 2.25rem;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--input-hover-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.label, .input, :deep(.button) {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selections {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: .5rem;
|
||||
overflow-y: scroll;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: .5rem 1rem;
|
||||
transition: $transition;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover, &.is-active {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,232 @@
|
|||
<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>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="open"
|
||||
:is-open="openValue"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
|
@ -9,13 +9,13 @@
|
|||
ref="popup"
|
||||
class="popup"
|
||||
:class="{
|
||||
'is-open': open,
|
||||
'has-overflow': props.hasOverflow && open
|
||||
'is-open': openValue,
|
||||
'has-overflow': props.hasOverflow && openValue
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
name="content"
|
||||
:is-open="open"
|
||||
:is-open="openValue"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
|
@ -23,7 +23,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {ref, watch} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
|
@ -31,24 +31,35 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const open = ref(false)
|
||||
watch(
|
||||
() => props.open,
|
||||
nowOpen => {
|
||||
openValue.value = nowOpen
|
||||
},
|
||||
)
|
||||
|
||||
const openValue = ref(false)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
openValue.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
openValue.value = !openValue.value
|
||||
}
|
||||
|
||||
onClickOutside(popup, () => {
|
||||
if (!open.value) {
|
||||
if (!openValue.value) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||
|
||||
function initState(value: string) {
|
||||
return {
|
||||
value,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Filter Input">
|
||||
<Variant
|
||||
title="With date values"
|
||||
:init-state="initState('dueDate < now && done = false && dueDate > now/w+1w')"
|
||||
>
|
||||
<template #default="{state}">
|
||||
<FilterInput v-model="state.value" />
|
||||
</template>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -0,0 +1,354 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, 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 {
|
||||
DATE_FIELDS,
|
||||
ASSIGNEE_FIELDS,
|
||||
AUTOCOMPLETE_FIELDS,
|
||||
AVAILABLE_FILTER_FIELDS,
|
||||
FILTER_JOIN_OPERATOR,
|
||||
FILTER_OPERATORS,
|
||||
FILTER_OPERATORS_REGEX, LABEL_FIELDS,
|
||||
} from '@/helpers/filters'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
projectId?: number,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur'])
|
||||
|
||||
const filterQuery = ref<string>('')
|
||||
const {
|
||||
textarea: filterInput,
|
||||
height,
|
||||
} = useAutoHeightTextarea(filterQuery)
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
() => {
|
||||
filterQuery.value = modelValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filterQuery.value,
|
||||
() => {
|
||||
if (filterQuery.value !== modelValue) {
|
||||
emit('update:modelValue', filterQuery.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const userService = new UserService()
|
||||
const projectUserService = new ProjectUserService()
|
||||
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function unEscapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
}
|
||||
|
||||
const highlightedFilterQuery = computed(() => {
|
||||
let highlighted = escapeHtml(filterQuery.value)
|
||||
DATE_FIELDS
|
||||
.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) => {
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
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>`
|
||||
})
|
||||
})
|
||||
ASSIGNEE_FIELDS
|
||||
.forEach(f => {
|
||||
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_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 = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
const label = labelStore.getLabelsByExactTitles([value])[0] || undefined
|
||||
|
||||
return `${f} ${token} <span class="filter-query__label_value" style="background-color: ${label?.hexColor}; color: ${label?.textColor}">${label?.title ?? value}<span>`
|
||||
})
|
||||
})
|
||||
FILTER_OPERATORS
|
||||
.map(o => ` ${escapeHtml(o)} `)
|
||||
.forEach(o => {
|
||||
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
|
||||
})
|
||||
AVAILABLE_FILTER_FIELDS.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()
|
||||
document.querySelectorAll('button.filter-query__date_value')
|
||||
.forEach(b => {
|
||||
b.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const button = event.target
|
||||
currentOldDatepickerValue.value = button?.innerText
|
||||
currentDatepickerValue.value = button?.innerText
|
||||
currentDatepickerPos.value = parseInt(button?.dataset.position)
|
||||
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)
|
||||
|
||||
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) {
|
||||
if (matched.startsWith('label')) {
|
||||
autocompleteResultType.value = 'labels'
|
||||
autocompleteResults.value = labelStore.filterLabelsByQuery([], keyword)
|
||||
}
|
||||
if (matched.startsWith('assignee')) {
|
||||
autocompleteResultType.value = 'assignees'
|
||||
if (projectId) {
|
||||
projectUserService.getAll({projectId}, {s: keyword})
|
||||
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
|
||||
} else {
|
||||
userService.getAll({}, {s: keyword})
|
||||
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
|
||||
}
|
||||
}
|
||||
if (!projectId && matched.startsWith('project')) {
|
||||
autocompleteResultType.value = 'projects'
|
||||
autocompleteResults.value = projectStore.searchProject(keyword)
|
||||
}
|
||||
autocompleteMatchText.value = keyword
|
||||
autocompleteMatchPosition.value = prefix.length - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function autocompleteSelect(value) {
|
||||
filterQuery.value = filterQuery.value.substring(0, autocompleteMatchPosition.value + 1) +
|
||||
(autocompleteResultType.value === 'labels'
|
||||
? value.title
|
||||
: value.username) +
|
||||
filterQuery.value.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
|
||||
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('filters.query.title') }}</label>
|
||||
<AutocompleteDropdown
|
||||
:options="autocompleteResults"
|
||||
@blur="filterInput?.blur()"
|
||||
@update:modelValue="autocompleteSelect"
|
||||
>
|
||||
<template
|
||||
#input="{ onKeydown, onFocusField }"
|
||||
>
|
||||
<div class="control filter-input">
|
||||
<textarea
|
||||
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="e => emit('blur', e)"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-input-highlight {
|
||||
span {
|
||||
&.filter-query__field {
|
||||
color: var(--code-literal);
|
||||
}
|
||||
|
||||
&.filter-query__operator {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
&.filter-query__join-operator {
|
||||
color: var(--code-section);
|
||||
}
|
||||
|
||||
&.filter-query__date_value_placeholder {
|
||||
display: inline-block;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&.filter-query__assignee_value, &.filter-query__label_value {
|
||||
border-radius: $radius;
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
button.filter-query__date_value {
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-input-highlight {
|
||||
height: 2.5em;
|
||||
line-height: 1.5;
|
||||
padding: .5em .75em;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,80 @@
|
|||
<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>></code>: {{ $t('filters.query.help.operators.greaterThan') }}</li>
|
||||
<li><code>>=</code>: {{ $t('filters.query.help.operators.greaterThanOrEqual') }}</li>
|
||||
<li><code><</code>: {{ $t('filters.query.help.operators.lessThan') }}</li>
|
||||
<li><code><=</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>&&</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 < now</code>: {{ $t('filters.query.help.examples.dueDatePast') }}</li>
|
||||
<li>
|
||||
<code>done = false && priority >= 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) && dueDate <= 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>
|
|
@ -25,6 +25,8 @@
|
|||
v-model="value"
|
||||
:has-title="true"
|
||||
class="filter-popup"
|
||||
@update:modelValue="emitChanges"
|
||||
@showResultsButtonClicked="() => modalOpen = false"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
|
@ -34,47 +36,38 @@ import {computed, ref, watch} from 'vue'
|
|||
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const modelValue = defineModel<TaskFilterParams>({})
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
if(props.modelValue === value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
const value = ref<TaskFilterParams>({})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(modelValue) => {
|
||||
() => modelValue.value,
|
||||
(modelValue: TaskFilterParams) => {
|
||||
value.value = modelValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function emitChanges(newValue: TaskFilterParams) {
|
||||
if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = newValue.filter
|
||||
modelValue.value.s = newValue.s
|
||||
}
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
// 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 {filter, s} = value.value
|
||||
const def = {...getDefaultTaskFilterParams()}
|
||||
|
||||
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
|
||||
const params = {filter, s}
|
||||
const defaultParams = {
|
||||
filter_by: def.filter_by,
|
||||
filter_value: def.filter_value,
|
||||
filter_comparator: def.filter_comparator,
|
||||
filter_concat: def.filter_concat,
|
||||
filter: def.filter,
|
||||
s: s ? def.s : undefined,
|
||||
}
|
||||
|
||||
|
@ -84,7 +77,7 @@ const hasFilters = computed(() => {
|
|||
const modalOpen = ref(false)
|
||||
|
||||
function clearFilters() {
|
||||
value.value = {...getDefaultParams()}
|
||||
value.value = {...getDefaultTaskFilterParams()}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,195 +2,35 @@
|
|||
<card
|
||||
class="filters has-overflow"
|
||||
:title="hasTitle ? $t('filters.title') : ''"
|
||||
role="search"
|
||||
>
|
||||
<FilterInput
|
||||
v-model="params.filter"
|
||||
:project-id="projectId"
|
||||
@blur="change()"
|
||||
/>
|
||||
|
||||
<div class="field is-flex is-flex-direction-column">
|
||||
<Fancycheckbox
|
||||
v-model="params.filter_include_nulls"
|
||||
@update:modelValue="change()"
|
||||
@blur="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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<FilterInputDocs />
|
||||
|
||||
<template
|
||||
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"
|
||||
v-if="hasFooter"
|
||||
#footer
|
||||
>
|
||||
<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>
|
||||
<x-button
|
||||
variant="primary"
|
||||
@click.prevent.stop="changeAndEmitButton"
|
||||
>
|
||||
{{ $t('filters.showResults') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
|
@ -200,419 +40,94 @@ export const ALPHABETICAL_SORT = 'title'
|
|||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs} from 'vue'
|
||||
import {camelCase} from 'camel-case'
|
||||
import {computed, ref} from 'vue'
|
||||
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 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'
|
||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
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'
|
||||
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
const {
|
||||
hasTitle= false,
|
||||
hasFooter = true,
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
hasTitle?: boolean,
|
||||
hasFooter?: boolean,
|
||||
modelValue: TaskFilterParams,
|
||||
}>()
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import ProjectService from '@/services/project'
|
||||
const emit = defineEmits(['update:modelValue', 'showResultsButtonClicked'])
|
||||
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => {
|
||||
if (route.name?.startsWith('project.')) {
|
||||
return Number(route.params.projectId)
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
hasTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
return undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
|
||||
const DEFAULT_PARAMS = {
|
||||
const params = ref<TaskFilterParams>({
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_include_nulls: true,
|
||||
filter_concat: 'or',
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
} as const
|
||||
|
||||
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 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()
|
||||
() => modelValue,
|
||||
(value: TaskFilterParams) => {
|
||||
const val = {...value}
|
||||
val.filter = transformFilterStringFromApi(
|
||||
val?.filter || '',
|
||||
labelId => labelStore.getLabelById(labelId)?.title,
|
||||
projectId => projectStore.projects.value[projectId]?.title || null,
|
||||
)
|
||||
params.value = val
|
||||
},
|
||||
{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()
|
||||
},
|
||||
})
|
||||
const labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
function change() {
|
||||
const newParams = {...params.value}
|
||||
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
|
||||
const filter = transformFilterStringForApi(
|
||||
params.value.filter,
|
||||
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
|
||||
projectTitle => projectStore.searchProject(projectTitle)[0]?.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
|
||||
}
|
||||
|
||||
emit('update:modelValue', newParams)
|
||||
}
|
||||
|
||||
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 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)
|
||||
function changeAndEmitButton() {
|
||||
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')
|
||||
emit('showResultsButtonClicked')
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -172,6 +172,7 @@ 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'
|
||||
|
||||
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
|
||||
|
||||
|
@ -210,8 +211,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) {
|
||||
|
@ -360,7 +361,15 @@ async function find(query: string) {
|
|||
found.value = []
|
||||
return
|
||||
}
|
||||
const results = await searchService.getAll({}, {s: query})
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
found.value = results
|
||||
.filter(m => {
|
||||
if(props.shareType === 'user' && m.id === currentUserId.value) {
|
||||
|
|
|
@ -77,7 +77,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['taskAdded'])
|
||||
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
|
||||
const {textarea: newTaskInput} = useAutoHeightTextarea(newTaskTitle)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
|
|
@ -6,6 +6,7 @@ import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueus
|
|||
export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
const minHeight = ref(0)
|
||||
const height = ref('')
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLTextAreaElement | null) {
|
||||
|
@ -19,18 +20,17 @@ 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'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
height.value = textareaEl.scrollHeight + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
textareaEl.style.height = height.value
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
minHeight.value = parseFloat(height.value)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
@ -68,5 +68,8 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
|||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
return {
|
||||
textarea,
|
||||
height,
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
|||
import {useRoute} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import TaskCollectionService, {getDefaultTaskFilterParams} from '@/services/taskCollection'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
@ -24,16 +24,6 @@ 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',
|
||||
}
|
||||
|
@ -67,7 +57,7 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
|
|||
|
||||
const projectId = computed(() => projectIdGetter())
|
||||
|
||||
const params = ref({...getDefaultParams()})
|
||||
const params = ref({...getDefaultTaskFilterParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
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')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = lorem && dueDate = now && labels = ipsum',
|
||||
(title: string) => {
|
||||
switch (title) {
|
||||
case 'lorem':
|
||||
return 1
|
||||
case 'ipsum':
|
||||
return 2
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1&& due_date = now && labels = 2')
|
||||
})
|
||||
|
||||
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,
|
||||
(title: string) => {
|
||||
switch (title) {
|
||||
case 'lorem':
|
||||
return 1
|
||||
case 'ipsum':
|
||||
return 2
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = 1&& due_date = now || project = 2')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = 1 && due_date = now && labels = 2',
|
||||
(id: number) => {
|
||||
switch (id) {
|
||||
case 1:
|
||||
return 'lorem'
|
||||
case 2:
|
||||
return 'ipsum'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = lorem&& dueDate = now && labels = 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 = lorem && due_date = now || project = ipsum',
|
||||
nullIdToTitleResolver,
|
||||
(id: number) => {
|
||||
switch (id) {
|
||||
case 1:
|
||||
return 'lorem'
|
||||
case 2:
|
||||
return 'ipsum'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,164 @@
|
|||
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 = [
|
||||
'done',
|
||||
'priority',
|
||||
'percentDone',
|
||||
...DATE_FIELDS,
|
||||
...ASSIGNEE_FIELDS,
|
||||
...LABEL_FIELDS,
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS = [
|
||||
'!=',
|
||||
'=',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'like',
|
||||
'in',
|
||||
'?=',
|
||||
]
|
||||
|
||||
export const FILTER_JOIN_OPERATOR = [
|
||||
'&&',
|
||||
'||',
|
||||
'(',
|
||||
')',
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)'
|
||||
|
||||
function getFieldPattern(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 = getFieldPattern(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) {
|
||||
const labelId = labelResolver(keyword.trim())
|
||||
if (labelId !== null) {
|
||||
filter = filter.replace(keyword, String(labelId))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// Transform projects to ids
|
||||
PROJECT_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(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) {
|
||||
const projectId = projectResolver(keyword.trim())
|
||||
if (projectId !== null) {
|
||||
filter = filter.replace(keyword, String(projectId))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform all attributes to snake case
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replace(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.replace(snakeCase(f), f)
|
||||
})
|
||||
|
||||
// Transform labels to their titles
|
||||
LABEL_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(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) {
|
||||
const labelTitle = labelResolver(Number(keyword.trim()))
|
||||
if (labelTitle !== null) {
|
||||
filter = filter.replace(keyword, labelTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform projects to ids
|
||||
PROJECT_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(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) {
|
||||
const project = projectResolver(Number(keyword.trim()))
|
||||
if (project !== null) {
|
||||
filter = filter.replace(keyword, project)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
|
@ -385,6 +385,7 @@
|
|||
"filters": {
|
||||
"title": "Filters",
|
||||
"clear": "Clear Filters",
|
||||
"showResults": "Show results",
|
||||
"attributes": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "The saved filter title goes here…",
|
||||
|
@ -415,6 +416,52 @@
|
|||
"edit": {
|
||||
"title": "Edit This Saved Filter",
|
||||
"success": "The filter was saved successfully."
|
||||
},
|
||||
"query": {
|
||||
"title": "Query",
|
||||
"placeholder": "Type a search or filter query…",
|
||||
"help": {
|
||||
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
|
||||
"link": "How does this work?",
|
||||
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
|
||||
"fields": {
|
||||
"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)"
|
||||
},
|
||||
"operators": {
|
||||
"intro": "The available operators for filtering include:",
|
||||
"notEqual": "Not equal to",
|
||||
"equal": "Equal to",
|
||||
"greaterThan": "Greater than",
|
||||
"greaterThanOrEqual": "Greater than or equal to",
|
||||
"lessThan": "Less than",
|
||||
"lessThanOrEqual": "Less than or equal to",
|
||||
"like": "Matches a pattern (using wildcard %)",
|
||||
"in": "Matches any value in a list"
|
||||
},
|
||||
"logicalOperators": {
|
||||
"intro": "To combine multiple conditions, you can use the following logical operators:",
|
||||
"and": "AND operator, matches if all conditions are true",
|
||||
"or": "OR operator, matches if any of the conditions are true",
|
||||
"parentheses": "Parentheses for grouping conditions"
|
||||
},
|
||||
"examples": {
|
||||
"intro": "Here are some examples of filter queries:",
|
||||
"priorityEqual": "Matches tasks with priority level 4",
|
||||
"dueDatePast": "Matches tasks with a due date in the past",
|
||||
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
|
||||
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
|
||||
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
|
@ -585,23 +632,42 @@
|
|||
"to": "To",
|
||||
"from": "From",
|
||||
"fromto": "{from} to {to}",
|
||||
"date": "Date",
|
||||
"ranges": {
|
||||
"today": "Today",
|
||||
|
||||
"thisWeek": "This Week",
|
||||
"restOfThisWeek": "The Rest of This Week",
|
||||
"nextWeek": "Next Week",
|
||||
"next7Days": "Next 7 Days",
|
||||
"lastWeek": "Last Week",
|
||||
|
||||
"thisMonth": "This Month",
|
||||
"restOfThisMonth": "The Rest of This Month",
|
||||
"nextMonth": "Next Month",
|
||||
"next30Days": "Next 30 Days",
|
||||
"lastMonth": "Last Month",
|
||||
|
||||
"thisYear": "This Year",
|
||||
"restOfThisYear": "The Rest of This Year"
|
||||
},
|
||||
"values": {
|
||||
"now": "Now",
|
||||
"startOfToday": "Start of today",
|
||||
"endOfToday": "End of today",
|
||||
"beginningOflastWeek": "Beginning of last week",
|
||||
"endOfLastWeek": "End of last week",
|
||||
"beginningOfThisWeek": "Beginning of this week",
|
||||
"endOfThisWeek": "End of this week",
|
||||
"startOfNextWeek": "Start of next week",
|
||||
"endOfNextWeek": "End of next week",
|
||||
"in7Days": "In 7 days",
|
||||
"beginningOfLastMonth": "Beginning of last month",
|
||||
"endOfLastMonth": "End of last month",
|
||||
"startOfThisMonth": "Start of this month",
|
||||
"endOfThisMonth": "End of this month",
|
||||
"startOfNextMonth": "Start of next month",
|
||||
"endOfNextMonth": "End of next month",
|
||||
"in30Days": "In 30 days",
|
||||
"startOfThisYear": "Beginning of this year",
|
||||
"endOfThisYear": "End of this year"
|
||||
}
|
||||
},
|
||||
"datemathHelp": {
|
||||
|
@ -920,7 +986,9 @@
|
|||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
|
||||
"admin": "Admin",
|
||||
"member": "Member"
|
||||
"member": "Member",
|
||||
"isPublic": "Public Team",
|
||||
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
|
||||
}
|
||||
},
|
||||
"keyboardShortcuts": {
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
import type {IAbstract} from './IAbstract'
|
||||
import type {IUser} from './IUser'
|
||||
import type {IFilter} from '@/types/IFilter'
|
||||
|
||||
interface Filters {
|
||||
sortBy: ('start_date' | 'done' | 'id' | 'position')[],
|
||||
orderBy: ('asc' | 'desc')[],
|
||||
filter: string,
|
||||
filterIncludeNulls: boolean,
|
||||
s: string,
|
||||
}
|
||||
|
||||
export interface ISavedFilter extends IAbstract {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
filters: IFilter
|
||||
filters: Filters
|
||||
|
||||
owner: IUser
|
||||
created: Date
|
||||
|
|
|
@ -10,6 +10,7 @@ export interface ITeam extends IAbstract {
|
|||
members: ITeamMember[]
|
||||
right: Right
|
||||
oidcId: string
|
||||
isPublic: boolean
|
||||
|
||||
createdBy: IUser
|
||||
created: Date
|
||||
|
|
|
@ -11,11 +11,9 @@ export default class SavedFilterModel extends AbstractModel<ISavedFilter> implem
|
|||
filters: ISavedFilter['filters'] = {
|
||||
sortBy: ['done', 'id'],
|
||||
orderBy: ['asc', 'desc'],
|
||||
filterBy: ['done'],
|
||||
filterValue: ['false'],
|
||||
filterComparator: ['equals'],
|
||||
filterConcat: 'and',
|
||||
filter: 'done = false',
|
||||
filterIncludeNulls: true,
|
||||
s: '',
|
||||
}
|
||||
|
||||
owner: IUser = {}
|
||||
|
|
|
@ -14,6 +14,7 @@ export default class TeamModel extends AbstractModel<ITeam> implements ITeam {
|
|||
members: ITeamMember[] = []
|
||||
right: Right = RIGHTS.READ
|
||||
oidcId = ''
|
||||
isPublic: boolean = false
|
||||
|
||||
createdBy: IUser = {} // FIXME: seems wrong
|
||||
created: Date = null
|
||||
|
|
|
@ -63,9 +63,6 @@ export default class SavedFilterService extends AbstractService<ISavedFilter> {
|
|||
// the filter values in snake_sćase for url parameters.
|
||||
model.filters = objectToCamelCase(model.filters)
|
||||
|
||||
// Make sure all filterValues are passes as strings. This is a requirement of the api.
|
||||
model.filters.filterValue = model.filters.filterValue.map(v => String(v))
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
|
|
|
@ -3,15 +3,22 @@ import TaskModel from '@/models/task'
|
|||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
// FIXME: unite with other filter params types
|
||||
export interface GetAllTasksParams {
|
||||
sort_by: ('start_date' | 'done' | 'id')[],
|
||||
order_by: ('asc' | 'asc' | 'desc')[],
|
||||
filter_by: 'start_date'[],
|
||||
filter_comparator: ('greater_equals' | 'less_equals')[],
|
||||
filter_value: [string, string] // [dateFrom, dateTo],
|
||||
filter_concat: 'and',
|
||||
export interface TaskFilterParams {
|
||||
sort_by: ('start_date' | 'done' | 'id' | 'position')[],
|
||||
order_by: ('asc' | 'desc')[],
|
||||
filter: string,
|
||||
filter_include_nulls: boolean,
|
||||
s: string,
|
||||
}
|
||||
|
||||
export function getDefaultTaskFilterParams(): TaskFilterParams {
|
||||
return {
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaskCollectionService extends AbstractService<ITask> {
|
||||
|
|
|
@ -37,6 +37,7 @@ export interface ConfigState {
|
|||
providers: IProvider[],
|
||||
},
|
||||
},
|
||||
publicTeamsEnabled: boolean,
|
||||
}
|
||||
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
|
@ -70,6 +71,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
providers: [],
|
||||
},
|
||||
},
|
||||
publicTeamsEnabled: false,
|
||||
})
|
||||
|
||||
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)
|
||||
|
|
|
@ -33,6 +33,10 @@ export const useLabelStore = defineStore('label', () => {
|
|||
const getLabelsByIds = computed(() => {
|
||||
return (ids: ILabel['id'][]) => Object.values(labels.value).filter(({id}) => ids.includes(id))
|
||||
})
|
||||
|
||||
const getLabelById = computed(() => {
|
||||
return (labelId: ILabel['id']) => Object.values(labels.value).find(({id}) => id === labelId)
|
||||
})
|
||||
|
||||
// **
|
||||
// * Checks if a project of labels is available in the store and filters them then query
|
||||
|
@ -138,6 +142,7 @@ export const useLabelStore = defineStore('label', () => {
|
|||
isLoading,
|
||||
|
||||
getLabelsByIds,
|
||||
getLabelById,
|
||||
filterLabelsByQuery,
|
||||
getLabelsByExactTitles,
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
export interface IFilter {
|
||||
sortBy: ('done' | 'id')[]
|
||||
orderBy: ('asc' | 'desc')[]
|
||||
filterBy: 'done'[]
|
||||
filterValue: 'false'[]
|
||||
filterComparator: 'equals'[]
|
||||
filterConcat: 'and'
|
||||
filterIncludeNulls: boolean
|
||||
}
|
|
@ -59,6 +59,7 @@
|
|||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
class="has-no-shadow has-no-border"
|
||||
:has-footer="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -296,6 +296,7 @@ import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
|||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const {
|
||||
projectId = undefined,
|
||||
|
@ -347,11 +348,12 @@ const collapsedBuckets = ref<CollapsedBuckets>({})
|
|||
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
|
||||
const oneTaskUpdating = ref(false)
|
||||
|
||||
const params = ref({
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_concat: 'and',
|
||||
const params = ref<TaskFilterParams>({
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
})
|
||||
|
||||
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
|
||||
|
|
|
@ -8,7 +8,7 @@ import {useRouteFilters} from '@/composables/useRouteFilters'
|
|||
import {useGanttTaskList} from './useGanttTaskList'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {GetAllTasksParams} from '@/services/taskCollection'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
|
@ -75,14 +75,11 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
|||
}
|
||||
}
|
||||
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
|
||||
return {
|
||||
sort_by: ['start_date', 'done', 'id'],
|
||||
order_by: ['asc', 'asc', 'desc'],
|
||||
filter_by: ['start_date', 'start_date'],
|
||||
filter_comparator: ['greater_equals', 'less_equals'],
|
||||
filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
|
||||
filter_concat: 'and',
|
||||
filter: 'start_date >= "' + isoToKebabDate(filters.dateFrom) + '" && start_date <= "' + isoToKebabDate(filters.dateTo) + '"',
|
||||
filter_include_nulls: filters.showTasksWithoutDates,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import {klona} from 'klona/lite'
|
|||
import type {Filters} from '@/composables/useRouteFilters'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
|
||||
import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
|
||||
import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
|
@ -13,7 +13,7 @@ import {error, success} from '@/message'
|
|||
// FIXME: unify with general `useTaskList`
|
||||
export function useGanttTaskList<F extends Filters>(
|
||||
filters: Ref<F>,
|
||||
filterToApiParams: (filters: F) => GetAllTasksParams,
|
||||
filterToApiParams: (filters: F) => TaskFilterParams,
|
||||
options: {
|
||||
loadAll?: boolean,
|
||||
} = {
|
||||
|
@ -26,7 +26,7 @@ export function useGanttTaskList<F extends Filters>(
|
|||
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
|
||||
async function fetchTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||
async function fetchTasks(params: TaskFilterParams, page = 1): Promise<ITask[]> {
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
|
||||
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await fetchTasks(params, page + 1)
|
||||
|
@ -40,7 +40,7 @@ export function useGanttTaskList<F extends Filters>(
|
|||
* Normally there is no need to trigger this manually
|
||||
*/
|
||||
async function loadTasks() {
|
||||
const params: GetAllTasksParams = filterToApiParams(filters.value)
|
||||
const params: TaskFilterParams = filterToApiParams(filters.value)
|
||||
|
||||
const loadedTasks = await fetchTasks(params)
|
||||
tasks.value = new Map()
|
||||
|
|
|
@ -33,6 +33,27 @@
|
|||
>
|
||||
{{ $t('team.attributes.nameRequired') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="configStore.publicTeamsEnabled"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="teamIsPublic"
|
||||
>{{ $t('team.attributes.isPublic') }}</label>
|
||||
<div
|
||||
class="control is-expanded"
|
||||
:class="{ 'is-loading': teamService.loading }"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="team.isPublic"
|
||||
:disabled="teamMemberService.loading || undefined"
|
||||
:class="{ 'disabled': teamService.loading }"
|
||||
>
|
||||
{{ $t('team.attributes.isPublicDescription') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
|
@ -242,6 +263,7 @@ import {useI18n} from 'vue-i18n'
|
|||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
|
||||
|
@ -254,12 +276,14 @@ import {RIGHTS as Rights} from '@/constants/rights'
|
|||
import {useTitle} from '@/composables/useTitle'
|
||||
import {success} from '@/message'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import type {ITeam} from '@/modelTypes/ITeam'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {ITeamMember} from '@/modelTypes/ITeamMember'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
|
|
@ -25,6 +25,26 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="configStore.publicTeamsEnabled"
|
||||
class="field"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
for="teamIsPublic"
|
||||
>{{ $t('team.attributes.isPublic') }}</label>
|
||||
<div
|
||||
class="control is-expanded"
|
||||
:class="{ 'is-loading': teamService.loading }"
|
||||
>
|
||||
<Fancycheckbox
|
||||
v-model="team.isPublic"
|
||||
:class="{ 'disabled': teamService.loading }"
|
||||
>
|
||||
{{ $t('team.attributes.isPublicDescription') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showError && team.name === ''"
|
||||
class="help is-danger"
|
||||
|
@ -46,11 +66,14 @@ import TeamModel from '@/models/team'
|
|||
import TeamService from '@/services/team'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n()
|
||||
const title = computed(() => t('team.create.title'))
|
||||
useTitle(title)
|
||||
|
@ -60,6 +83,8 @@ const teamService = shallowReactive(new TeamService())
|
|||
const team = reactive(new TeamModel())
|
||||
const showError = ref(false)
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
async function newTeam() {
|
||||
if (team.name === '') {
|
||||
showError.value = true
|
||||
|
|
1
go.mod
1
go.mod
|
@ -31,6 +31,7 @@ require (
|
|||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/ganigeorgiev/fexpr v0.4.0
|
||||
github.com/getsentry/sentry-go v0.27.0
|
||||
github.com/go-sql-driver/mysql v1.8.0
|
||||
github.com/go-testfixtures/testfixtures/v3 v3.10.0
|
||||
|
|
6
go.sum
6
go.sum
|
@ -109,6 +109,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
|
|||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
|
||||
github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
|
@ -150,8 +152,6 @@ github.com/go-playground/validator/v10 v10.15.1 h1:BSe8uhN+xQ4r5guV/ywQI4gO59C2r
|
|||
github.com/go-playground/validator/v10 v10.15.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
|
||||
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
|
@ -167,8 +167,6 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
|
|||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
|
|
|
@ -415,7 +415,7 @@ func checkGolangCiLintInstalled() {
|
|||
mg.Deps(initVars)
|
||||
if err := exec.Command("golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
|
||||
fmt.Println("Please manually install golangci-lint by running")
|
||||
fmt.Println("curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2")
|
||||
fmt.Println("curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.56.2")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,10 +32,10 @@ func init() {
|
|||
var dumpCmd = &cobra.Command{
|
||||
Use: "dump",
|
||||
Short: "Dump all vikunja data into a zip file. Includes config, files and db.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInitWithoutAsync()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
filename := "vikunja-dump_" + time.Now().Format("2006-01-02_15-03-05") + ".zip"
|
||||
if err := dump.Dump(filename); err != nil {
|
||||
log.Critical(err.Error())
|
||||
|
|
|
@ -32,10 +32,10 @@ func init() {
|
|||
var indexCmd = &cobra.Command{
|
||||
Use: "index",
|
||||
Short: "Reindex all of Vikunja's data into Typesense. This will remove any existing index.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInitWithoutAsync()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
if config.TypesenseURL.GetString() == "" {
|
||||
log.Error("Typesense not configured")
|
||||
return
|
||||
|
@ -61,10 +61,10 @@ var indexCmd = &cobra.Command{
|
|||
var partialReindexCmd = &cobra.Command{
|
||||
Use: "partial-index",
|
||||
Short: "Reindex any tasks which were not indexed yet into Typesense. This will not remove any existing index.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInitWithoutAsync()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
if config.TypesenseURL.GetString() == "" {
|
||||
log.Error("Typesense not configured")
|
||||
return
|
||||
|
|
|
@ -36,10 +36,10 @@ func init() {
|
|||
var migrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Run all database migrations which didn't already run.",
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.LightInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
migration.Migrate(nil)
|
||||
},
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ var migrateCmd = &cobra.Command{
|
|||
var migrateListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Show a list with all database migrations.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
migration.ListMigrations()
|
||||
},
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ var rollbackUntilFlag string
|
|||
var migrationRollbackCmd = &cobra.Command{
|
||||
Use: "rollback",
|
||||
Short: "Roll migrations back until a certain point.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
migration.Rollback(rollbackUntilFlag)
|
||||
},
|
||||
}
|
||||
|
|
|
@ -31,10 +31,10 @@ var restoreCmd = &cobra.Command{
|
|||
Use: "restore [filename]",
|
||||
Short: "Restores all vikunja data from a vikunja dump.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInitWithoutAsync()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if err := dump.Restore(args[0]); err != nil {
|
||||
log.Critical(err.Error())
|
||||
}
|
||||
|
|
|
@ -33,13 +33,13 @@ var testmailCmd = &cobra.Command{
|
|||
Use: "testmail [email]",
|
||||
Short: "Send a test mail using the configured smtp connection",
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.LightInit()
|
||||
|
||||
// Start the mail daemon
|
||||
mail.StartMailDaemon()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
log.Info("Sending testmail...")
|
||||
message := notifications.NewMail().
|
||||
From("Vikunja <"+config.MailerFromEmail.GetString()+">").
|
||||
|
|
|
@ -128,10 +128,10 @@ var userCmd = &cobra.Command{
|
|||
var userListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Shows a list of all users.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -173,10 +173,10 @@ var userListCmd = &cobra.Command{
|
|||
var userCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new user.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -214,10 +214,10 @@ var userUpdateCmd = &cobra.Command{
|
|||
Use: "update [user id]",
|
||||
Short: "Update an existing user.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -250,11 +250,11 @@ var userUpdateCmd = &cobra.Command{
|
|||
var userResetPasswordCmd = &cobra.Command{
|
||||
Use: "reset-password [user id]",
|
||||
Short: "Reset a users password, either through mailing them a reset link or directly.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -286,11 +286,11 @@ var userResetPasswordCmd = &cobra.Command{
|
|||
var userChangeStatusCmd = &cobra.Command{
|
||||
Use: "change-status [user id]",
|
||||
Short: "Enable or disable a user. Will toggle the current status if no flag (--enable or --disable) is provided.",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -327,10 +327,10 @@ var userDeleteCmd = &cobra.Command{
|
|||
Short: "Delete an existing user.",
|
||||
Long: "Kick off the user deletion process. If call 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. With the flag the user is deleted immediately. USE WITH CAUTION.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if userFlagDeleteNow && !userFlagDeleteConfirm {
|
||||
fmt.Println("You requested to delete the user immediately. Are you sure?")
|
||||
fmt.Println(`To confirm, please type "yes, I confirm" in all uppercase:`)
|
||||
|
|
|
@ -31,7 +31,7 @@ func init() {
|
|||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version number of Vikunja",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Printf("Vikunja api version %s\n", version.Version)
|
||||
fmt.Printf("Built with %s\n", runtime.Version())
|
||||
},
|
||||
|
|
|
@ -67,10 +67,10 @@ func setupUnixSocket(e *echo.Echo) error {
|
|||
var webCmd = &cobra.Command{
|
||||
Use: "web",
|
||||
Short: "Starts the rest api web server",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
initialize.FullInit()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
// Version notification
|
||||
log.Infof("Vikunja version %s", version.Version)
|
||||
|
|
|
@ -64,6 +64,7 @@ const (
|
|||
ServiceMaxAvatarSize Key = `service.maxavatarsize`
|
||||
ServiceAllowIconChanges Key = `service.allowiconchanges`
|
||||
ServiceCustomLogoURL Key = `service.customlogourl`
|
||||
ServiceEnablePublicTeams Key = `service.enablepublicteams`
|
||||
|
||||
SentryEnabled Key = `sentry.enabled`
|
||||
SentryDsn Key = `sentry.dsn`
|
||||
|
@ -312,6 +313,7 @@ func InitDefaultConfig() {
|
|||
ServiceMaxAvatarSize.setDefault(1024)
|
||||
ServiceDemoMode.setDefault(false)
|
||||
ServiceAllowIconChanges.setDefault(true)
|
||||
ServiceEnablePublicTeams.setDefault(false)
|
||||
|
||||
// Sentry
|
||||
SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944")
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
- id: 1
|
||||
filters: '{"sort_by":null,"order_by":null,"filter_by":["start_date","end_date","due_date"],"filter_value":["2018-12-11T03:46:40+00:00","2018-12-13T11:20:01+00:00","2018-11-29T14:00:00+00:00"],"filter_comparator":["greater","less","greater"],"filter_concat":"","filter_include_nulls":false}'
|
||||
filters: '{"sort_by":null,"order_by":null,"filter":"start_date > \u00272018-12-11T03:46:40+00:00\u0027 || end_date < \u00272018-12-13T11:20:01+00:00\u0027 || due_date > \u00272018-11-29T14:00:00+00:00\u0027","filter_include_nulls":false}'
|
||||
title: testfilter1
|
||||
owner_id: 1
|
||||
updated: 2020-09-08 15:13:12
|
||||
|
|
|
@ -1,61 +1,49 @@
|
|||
-
|
||||
team_id: 1
|
||||
- team_id: 1
|
||||
user_id: 1
|
||||
admin: true
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 1
|
||||
- team_id: 1
|
||||
user_id: 2
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 2
|
||||
- team_id: 2
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 3
|
||||
- team_id: 3
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 4
|
||||
- team_id: 4
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 5
|
||||
- team_id: 5
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 6
|
||||
- team_id: 6
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 7
|
||||
- team_id: 7
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 8
|
||||
- team_id: 8
|
||||
user_id: 1
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 9
|
||||
- team_id: 9
|
||||
user_id: 2
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 10
|
||||
- team_id: 10
|
||||
user_id: 3
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 11
|
||||
- team_id: 11
|
||||
user_id: 8
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 12
|
||||
- team_id: 12
|
||||
user_id: 9
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 13
|
||||
- team_id: 13
|
||||
user_id: 10
|
||||
created: 2018-12-01 15:13:12
|
||||
-
|
||||
team_id: 14
|
||||
- team_id: 14
|
||||
user_id: 10
|
||||
created: 2018-12-01 15:13:12
|
||||
created: 2018-12-01 15:13:12
|
||||
- team_id: 15
|
||||
user_id: 10
|
||||
created: 2018-12-01 15:13:12
|
||||
|
|
|
@ -29,8 +29,16 @@
|
|||
- id: 13
|
||||
name: testteam13
|
||||
created_by_id: 7
|
||||
is_public: true
|
||||
- id: 14
|
||||
name: testteam14
|
||||
created_by_id: 7
|
||||
oidc_id: 14
|
||||
issuer: "https://some.issuer"
|
||||
issuer: "https://some.issuer"
|
||||
- id: 15
|
||||
name: testteam15
|
||||
created_by_id: 7
|
||||
oidc_id: 15
|
||||
issuer: "https://some.issuer"
|
||||
is_public: true
|
||||
description: "This is a public team"
|
||||
|
|
|
@ -120,7 +120,7 @@ func addUserTokenToContext(t *testing.T, user *user.User, c echo.Context) {
|
|||
token, err := auth.NewUserJWTAuthtoken(user, false)
|
||||
require.NoError(t, err)
|
||||
// We send the string token through the parsing function to get a valid jwt.Token
|
||||
tken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
tken, err := jwt.Parse(token, func(_ *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.ServiceJWTSecret.GetString()), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
@ -132,7 +132,7 @@ func addLinkShareTokenToContext(t *testing.T, share *models.LinkSharing, c echo.
|
|||
token, err := auth.NewLinkShareJWTAuthtoken(share)
|
||||
require.NoError(t, err)
|
||||
// We send the string token through the parsing function to get a valid jwt.Token
|
||||
tken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
tken, err := jwt.Parse(token, func(_ *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.ServiceJWTSecret.GetString()), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -184,9 +184,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("start and end date", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"start_date", "end_date", "due_date"},
|
||||
"filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
|
||||
"filter_comparator": []string{"greater", "less", "greater"},
|
||||
"filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
|
||||
},
|
||||
urlParams,
|
||||
)
|
||||
|
@ -209,9 +207,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("start date only", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"start_date"},
|
||||
"filter_value": []string{"2018-10-20T01:46:40+00:00"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
|
||||
},
|
||||
urlParams,
|
||||
)
|
||||
|
@ -234,9 +230,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("end date only", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"end_date"},
|
||||
"filter_value": []string{"2018-12-13T11:20:01+00:00"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
|
||||
},
|
||||
urlParams,
|
||||
)
|
||||
|
@ -249,9 +243,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("unix timestamps", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"start_date", "end_date", "due_date"},
|
||||
"filter_value": []string{"1544500000", "1513164001", "1543500000"},
|
||||
"filter_comparator": []string{"greater", "less", "greater"},
|
||||
"filter": []string{"start_date > 1544500000 || end_date < 1513164001 || due_date > 1543500000"},
|
||||
},
|
||||
urlParams,
|
||||
)
|
||||
|
@ -275,9 +267,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("invalid date", func(t *testing.T) {
|
||||
_, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"due_date"},
|
||||
"filter_value": []string{"invalid"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"due_date > invalid"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
@ -411,9 +401,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("start and end date", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"start_date", "end_date", "due_date"},
|
||||
"filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
|
||||
"filter_comparator": []string{"greater", "less", "greater"},
|
||||
"filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
@ -436,9 +424,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("start date only", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"start_date"},
|
||||
"filter_value": []string{"2018-10-20T01:46:40+00:00"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
@ -461,9 +447,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("end date only", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"end_date"},
|
||||
"filter_value": []string{"2018-12-13T11:20:01+00:00"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
@ -477,9 +461,7 @@ func TestTaskCollection(t *testing.T) {
|
|||
t.Run("invalid date", func(t *testing.T) {
|
||||
_, err := testHandler.testReadAllWithUser(
|
||||
url.Values{
|
||||
"filter_by": []string{"due_date"},
|
||||
"filter_value": []string{"invalid"},
|
||||
"filter_comparator": []string{"greater"},
|
||||
"filter": []string{"due_date > invalid"},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
|
|
@ -50,8 +50,8 @@ func getClient() (*mail.Client, error) {
|
|||
opts := []mail.Option{
|
||||
mail.WithPort(config.MailerPort.GetInt()),
|
||||
mail.WithTLSPolicy(tlsPolicy),
|
||||
//#nosec G402
|
||||
mail.WithTLSConfig(&tls.Config{
|
||||
//#nosec G402
|
||||
InsecureSkipVerify: config.MailerSkipTLSVerify.GetBool(),
|
||||
ServerName: config.MailerHost.GetString(),
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public Licensee as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type taskCollectionFilter20231121191822 struct {
|
||||
SortBy []string `query:"sort_by" json:"sort_by"`
|
||||
OrderBy []string `query:"order_by" json:"order_by"`
|
||||
|
||||
FilterBy []string `query:"filter_by" json:"filter_by,omitempty"`
|
||||
FilterValue []string `query:"filter_value" json:"filter_value,omitempty"`
|
||||
FilterComparator []string `query:"filter_comparator" json:"filter_comparator,omitempty"`
|
||||
FilterConcat string `query:"filter_concat" json:"filter_concat,omitempty"`
|
||||
|
||||
Filter string `query:"filter" json:"filter"`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
|
||||
}
|
||||
|
||||
type savedFilter20231121191822 struct {
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
|
||||
Filters *taskCollectionFilter20231121191822 `xorm:"JSON not null" json:"filters" valid:"required"`
|
||||
}
|
||||
|
||||
func (savedFilter20231121191822) TableName() string {
|
||||
return "saved_filters"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20231121191822",
|
||||
Description: "Migrate saved filter structure",
|
||||
Migrate: func(tx *xorm.Engine) (err error) {
|
||||
allFilters := []*savedFilter20231121191822{}
|
||||
err = tx.Find(&allFilters)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, filter := range allFilters {
|
||||
var filterStrings []string
|
||||
for i, f := range filter.Filters.FilterBy {
|
||||
var comparator string
|
||||
switch filter.Filters.FilterComparator[i] {
|
||||
case "equals":
|
||||
comparator = "="
|
||||
case "greater":
|
||||
comparator = ">"
|
||||
case "greater_equals":
|
||||
comparator = ">="
|
||||
case "less":
|
||||
comparator = "<"
|
||||
case "less_equals":
|
||||
comparator = "<="
|
||||
case "not_equals":
|
||||
comparator = "!="
|
||||
case "like":
|
||||
comparator = "~"
|
||||
case "in":
|
||||
comparator = "?="
|
||||
}
|
||||
filterStrings = append(filterStrings, f+" "+comparator+" "+filter.Filters.FilterValue[i])
|
||||
}
|
||||
|
||||
filter.Filters.FilterConcat = " || "
|
||||
if filter.Filters.FilterConcat == "and" {
|
||||
filter.Filters.FilterConcat = " && "
|
||||
}
|
||||
filter.Filters.Filter = strings.Join(filterStrings, filter.Filters.FilterConcat)
|
||||
|
||||
filter.Filters.FilterBy = nil
|
||||
filter.Filters.FilterComparator = nil
|
||||
filter.Filters.FilterValue = nil
|
||||
filter.Filters.FilterConcat = ""
|
||||
|
||||
_, err = tx.Where("id = ?", filter.ID).Update(filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public Licensee as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type teams20240309111148 struct {
|
||||
IsPublic bool `xorm:"not null default false" json:"is_public"`
|
||||
}
|
||||
|
||||
func (teams20240309111148) TableName() string {
|
||||
return "teams"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20240309111148",
|
||||
Description: "Add IsPublic field to teams table to control discoverability of teams.",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync2(teams20240309111148{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1021,7 +1021,7 @@ func (err ErrTaskRelationCycle) Error() string {
|
|||
}
|
||||
|
||||
// ErrCodeTaskRelationCycle holds the unique world-error code of this error
|
||||
const ErrCodeTaskRelationCycle = 4022
|
||||
const ErrCodeTaskRelationCycle = 4023
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
|
||||
|
@ -1032,6 +1032,34 @@ func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
|
|||
}
|
||||
}
|
||||
|
||||
// ErrInvalidFilterExpression represents an error where the task filter expression was invalid
|
||||
type ErrInvalidFilterExpression struct {
|
||||
Expression string
|
||||
ExpressionError error
|
||||
}
|
||||
|
||||
// IsErrInvalidFilterExpression checks if an error is ErrInvalidFilterExpression.
|
||||
func IsErrInvalidFilterExpression(err error) bool {
|
||||
_, ok := err.(ErrInvalidFilterExpression)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrInvalidFilterExpression) Error() string {
|
||||
return fmt.Sprintf("Task filter expression '%s' is invalid [ExpressionError: %v]", err.Expression, err.ExpressionError)
|
||||
}
|
||||
|
||||
// ErrCodeInvalidFilterExpression holds the unique world-error code of this error
|
||||
const ErrCodeInvalidFilterExpression = 4024
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeInvalidFilterExpression,
|
||||
Message: fmt.Sprintf("The filter expression '%s' is invalid: %v", err.Expression, err.ExpressionError),
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Team errors
|
||||
// ============
|
||||
|
|
|
@ -17,6 +17,8 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
|
@ -106,10 +108,7 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err
|
|||
// @Param page query int false "The page number for tasks. Used for pagination. If not provided, the first page of results is returned."
|
||||
// @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page."
|
||||
// @Param s query string false "Search tasks by task text."
|
||||
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
|
||||
// @Param filter_value query string false "The value to filter for."
|
||||
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
|
||||
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
|
||||
// @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature."
|
||||
// @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`."
|
||||
// @Success 200 {array} models.Bucket "The buckets with their tasks"
|
||||
// @Failure 500 {object} models.Message "Internal server error"
|
||||
|
@ -173,28 +172,36 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
|
|||
opts.page = page
|
||||
opts.perPage = perPage
|
||||
opts.search = search
|
||||
opts.filterConcat = filterConcatAnd
|
||||
|
||||
var bucketFilterIndex int
|
||||
for i, filter := range opts.filters {
|
||||
for _, filter := range opts.parsedFilters {
|
||||
if filter.field == taskPropertyBucketID {
|
||||
bucketFilterIndex = i
|
||||
|
||||
// Limiting the map to the one filter we're looking for is the easiest way to ensure we only
|
||||
// get tasks in this bucket
|
||||
bucketID := filter.value.(int64)
|
||||
bucket := bucketMap[bucketID]
|
||||
|
||||
bucketMap = make(map[int64]*Bucket, 1)
|
||||
bucketMap[bucketID] = bucket
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if bucketFilterIndex == 0 {
|
||||
opts.filters = append(opts.filters, &taskFilter{
|
||||
field: taskPropertyBucketID,
|
||||
value: 0,
|
||||
comparator: taskFilterComparatorEquals,
|
||||
})
|
||||
bucketFilterIndex = len(opts.filters) - 1
|
||||
}
|
||||
|
||||
originalFilter := opts.filter
|
||||
for id, bucket := range bucketMap {
|
||||
|
||||
opts.filters[bucketFilterIndex].value = id
|
||||
if !strings.Contains(originalFilter, "bucket_id") {
|
||||
var filterString string
|
||||
if originalFilter == "" {
|
||||
filterString = "bucket_id = " + strconv.FormatInt(id, 10)
|
||||
} else {
|
||||
filterString = "(" + originalFilter + ") && bucket_id = " + strconv.FormatInt(id, 10)
|
||||
}
|
||||
opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: bucket.ProjectID}}, auth, opts)
|
||||
if err != nil {
|
||||
|
|
|
@ -81,9 +81,7 @@ func TestBucket_ReadAll(t *testing.T) {
|
|||
b := &Bucket{
|
||||
ProjectID: 1,
|
||||
TaskCollection: TaskCollection{
|
||||
FilterBy: []string{"title"},
|
||||
FilterComparator: []string{"like"},
|
||||
FilterValue: []string{"done"},
|
||||
Filter: "title ~ 'done'",
|
||||
},
|
||||
}
|
||||
bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
|
||||
|
@ -94,6 +92,30 @@ func TestBucket_ReadAll(t *testing.T) {
|
|||
assert.Equal(t, int64(2), buckets[0].Tasks[0].ID)
|
||||
assert.Equal(t, int64(33), buckets[0].Tasks[1].ID)
|
||||
})
|
||||
t.Run("filtered by bucket", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
testuser := &user.User{ID: 1}
|
||||
b := &Bucket{
|
||||
ProjectID: 1,
|
||||
TaskCollection: TaskCollection{
|
||||
Filter: "title ~ 'task' && bucket_id = 2",
|
||||
},
|
||||
}
|
||||
bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
buckets := bucketsInterface.([]*Bucket)
|
||||
assert.Len(t, buckets, 3)
|
||||
assert.Empty(t, buckets[0].Tasks, 0)
|
||||
assert.Len(t, buckets[1].Tasks, 3)
|
||||
assert.Empty(t, buckets[2].Tasks, 0)
|
||||
assert.Equal(t, int64(3), buckets[1].Tasks[0].ID)
|
||||
assert.Equal(t, int64(4), buckets[1].Tasks[1].ID)
|
||||
assert.Equal(t, int64(5), buckets[1].Tasks[2].ID)
|
||||
})
|
||||
t.Run("accessed by link share", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
|
|
|
@ -354,9 +354,6 @@ func TestProject_ReadAll(t *testing.T) {
|
|||
assert.Len(t, projects, 25)
|
||||
_ = s.Close()
|
||||
})
|
||||
t.Run("only child projects for one project", func(t *testing.T) {
|
||||
// TODO
|
||||
})
|
||||
t.Run("all projects for user", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
|
|
|
@ -65,7 +65,7 @@ func TestSavedFilter_Create(t *testing.T) {
|
|||
vals := map[string]interface{}{
|
||||
"title": "'test'",
|
||||
"description": "'Lorem Ipsum dolor sit amet'",
|
||||
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'",
|
||||
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter\":\"\",\"filter_include_nulls\":false}'",
|
||||
"owner_id": 1,
|
||||
}
|
||||
// Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721
|
||||
|
|
|
@ -33,17 +33,8 @@ type TaskCollection struct {
|
|||
OrderBy []string `query:"order_by" json:"order_by"`
|
||||
OrderByArr []string `query:"order_by[]" json:"-"`
|
||||
|
||||
// The field name of the field to filter by
|
||||
FilterBy []string `query:"filter_by" json:"filter_by"`
|
||||
FilterByArr []string `query:"filter_by[]" json:"-"`
|
||||
// The value of the field name to filter by
|
||||
FilterValue []string `query:"filter_value" json:"filter_value"`
|
||||
FilterValueArr []string `query:"filter_value[]" json:"-"`
|
||||
// The comparator for field and value
|
||||
FilterComparator []string `query:"filter_comparator" json:"filter_comparator"`
|
||||
FilterComparatorArr []string `query:"filter_comparator[]" json:"-"`
|
||||
// The way all filter conditions are concatenated together, can be either "and" or "or".,
|
||||
FilterConcat string `query:"filter_concat" json:"filter_concat"`
|
||||
Filter string `query:"filter" json:"filter"`
|
||||
|
||||
// If set to true, the result will also include null values
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
|
||||
|
||||
|
@ -110,11 +101,11 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
|
|||
|
||||
opts = &taskSearchOptions{
|
||||
sortby: sort,
|
||||
filterConcat: taskFilterConcatinator(tf.FilterConcat),
|
||||
filterIncludeNulls: tf.FilterIncludeNulls,
|
||||
filter: tf.Filter,
|
||||
}
|
||||
|
||||
opts.filters, err = getTaskFiltersByCollections(tf)
|
||||
opts.parsedFilters, err = getTaskFiltersFromFilterString(tf.Filter)
|
||||
return opts, err
|
||||
}
|
||||
|
||||
|
@ -130,10 +121,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
|
|||
// @Param s query string false "Search tasks by task text."
|
||||
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
|
||||
// @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`."
|
||||
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
|
||||
// @Param filter_value query string false "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc."
|
||||
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
|
||||
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
|
||||
// @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature."
|
||||
// @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`."
|
||||
// @Security JWTKeyAuth
|
||||
// @Success 200 {array} models.Task "The tasks"
|
||||
|
|
|
@ -23,6 +23,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ganigeorgiev/fexpr"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
|
@ -54,6 +56,7 @@ type taskFilter struct {
|
|||
value interface{} // Needs to be an interface to be able to hold the field's native value
|
||||
comparator taskFilterComparator
|
||||
isNumeric bool
|
||||
join taskFilterConcatinator
|
||||
}
|
||||
|
||||
func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
|
||||
|
@ -88,61 +91,83 @@ func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
|
|||
return value.In(config.GetTimeZone()), err
|
||||
}
|
||||
|
||||
func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err error) {
|
||||
|
||||
if len(c.FilterByArr) > 0 {
|
||||
c.FilterBy = append(c.FilterBy, c.FilterByArr...)
|
||||
func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error) {
|
||||
filter = &taskFilter{
|
||||
join: filterConcatAnd,
|
||||
}
|
||||
if f.Join == fexpr.JoinOr {
|
||||
filter.join = filterConcatOr
|
||||
}
|
||||
|
||||
if len(c.FilterValueArr) > 0 {
|
||||
c.FilterValue = append(c.FilterValue, c.FilterValueArr...)
|
||||
}
|
||||
|
||||
if len(c.FilterComparatorArr) > 0 {
|
||||
c.FilterComparator = append(c.FilterComparator, c.FilterComparatorArr...)
|
||||
}
|
||||
|
||||
if c.FilterConcat != "" && c.FilterConcat != filterConcatAnd && c.FilterConcat != filterConcatOr {
|
||||
return nil, ErrInvalidTaskFilterConcatinator{
|
||||
Concatinator: taskFilterConcatinator(c.FilterConcat),
|
||||
}
|
||||
}
|
||||
|
||||
filters = make([]*taskFilter, 0, len(c.FilterBy))
|
||||
for i, f := range c.FilterBy {
|
||||
filter := &taskFilter{
|
||||
field: f,
|
||||
comparator: taskFilterComparatorEquals,
|
||||
}
|
||||
|
||||
if len(c.FilterComparator) > i {
|
||||
filter.comparator, err = getFilterComparatorFromString(c.FilterComparator[i])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = validateTaskFieldComparator(filter.comparator)
|
||||
var value string
|
||||
switch v := f.Item.(type) {
|
||||
case fexpr.Expr:
|
||||
filter.field = v.Left.Literal
|
||||
value = v.Right.Literal
|
||||
filter.comparator, err = getFilterComparatorFromOp(v.Op)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cast the field value to its native type
|
||||
var reflectValue *reflect.StructField
|
||||
if len(c.FilterValue) > i {
|
||||
reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, c.FilterValue[i])
|
||||
case []fexpr.ExprGroup:
|
||||
values := make([]*taskFilter, 0, len(v))
|
||||
for _, expression := range v {
|
||||
subfilter, err := parseFilterFromExpression(expression)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTaskFilterValue{
|
||||
Value: filter.field,
|
||||
Field: c.FilterValue[i],
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, subfilter)
|
||||
}
|
||||
if reflectValue != nil {
|
||||
filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
|
||||
}
|
||||
filter.value = values
|
||||
return
|
||||
}
|
||||
|
||||
filters = append(filters, filter)
|
||||
err = validateTaskFieldComparator(filter.comparator)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cast the field value to its native type
|
||||
var reflectValue *reflect.StructField
|
||||
if filter.field == "project" {
|
||||
filter.field = "project_id"
|
||||
}
|
||||
reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, value)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTaskFilterValue{
|
||||
Value: filter.field,
|
||||
Field: value,
|
||||
}
|
||||
}
|
||||
if reflectValue != nil {
|
||||
filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func getTaskFiltersFromFilterString(filter string) (filters []*taskFilter, err error) {
|
||||
|
||||
if filter == "" {
|
||||
return
|
||||
}
|
||||
|
||||
filter = strings.ReplaceAll(filter, " in ", " ?= ")
|
||||
|
||||
parsedFilter, err := fexpr.Parse(filter)
|
||||
if err != nil {
|
||||
return nil, &ErrInvalidFilterExpression{
|
||||
Expression: filter,
|
||||
ExpressionError: err,
|
||||
}
|
||||
}
|
||||
|
||||
filters = make([]*taskFilter, 0, len(parsedFilter))
|
||||
for _, f := range parsedFilter {
|
||||
parsedFilter, err := parseFilterFromExpression(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters = append(filters, parsedFilter)
|
||||
}
|
||||
|
||||
return
|
||||
|
@ -167,26 +192,28 @@ func validateTaskFieldComparator(comparator taskFilterComparator) error {
|
|||
}
|
||||
}
|
||||
|
||||
func getFilterComparatorFromString(comparator string) (taskFilterComparator, error) {
|
||||
switch comparator {
|
||||
case "equals":
|
||||
func getFilterComparatorFromOp(op fexpr.SignOp) (taskFilterComparator, error) {
|
||||
switch op {
|
||||
case fexpr.SignEq:
|
||||
return taskFilterComparatorEquals, nil
|
||||
case "greater":
|
||||
case fexpr.SignGt:
|
||||
return taskFilterComparatorGreater, nil
|
||||
case "greater_equals":
|
||||
case fexpr.SignGte:
|
||||
return taskFilterComparatorGreateEquals, nil
|
||||
case "less":
|
||||
case fexpr.SignLt:
|
||||
return taskFilterComparatorLess, nil
|
||||
case "less_equals":
|
||||
case fexpr.SignLte:
|
||||
return taskFilterComparatorLessEquals, nil
|
||||
case "not_equals":
|
||||
case fexpr.SignNeq:
|
||||
return taskFilterComparatorNotEquals, nil
|
||||
case "like":
|
||||
case fexpr.SignLike:
|
||||
return taskFilterComparatorLike, nil
|
||||
case fexpr.SignAnyEq:
|
||||
fallthrough
|
||||
case "in":
|
||||
return taskFilterComparatorIn, nil
|
||||
default:
|
||||
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(comparator)}
|
||||
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(op)}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,8 @@ import (
|
|||
"gopkg.in/d4l3k/messagediff.v1"
|
||||
)
|
||||
|
||||
// To only run a selected tests: ^\QTestTaskCollection_ReadAll\E$/^\QReadAll_Tasks_with_range\E$
|
||||
|
||||
func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
// Dummy users
|
||||
user1 := &user.User{
|
||||
|
@ -675,10 +677,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
SortBy []string // Is a string, since this is the place where a query string comes from the user
|
||||
OrderBy []string
|
||||
|
||||
FilterBy []string
|
||||
FilterValue []string
|
||||
FilterComparator []string
|
||||
FilterIncludeNulls bool
|
||||
Filter string
|
||||
|
||||
CRUDable web.CRUDable
|
||||
Rights web.Rights
|
||||
|
@ -792,9 +792,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "ReadAll Tasks with range",
|
||||
fields: fields{
|
||||
FilterBy: []string{"start_date", "end_date"},
|
||||
FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
|
||||
FilterComparator: []string{"greater", "less"},
|
||||
Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -807,9 +805,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "ReadAll Tasks with different range",
|
||||
fields: fields{
|
||||
FilterBy: []string{"start_date", "end_date"},
|
||||
FilterValue: []string{"2018-12-13T11:20:00+00:00", "2018-12-16T22:40:00+00:00"},
|
||||
FilterComparator: []string{"greater", "less"},
|
||||
Filter: "start_date > '2018-12-13T11:20:00+00:00' || end_date < '2018-12-16T22:40:00+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -821,20 +817,16 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "ReadAll Tasks with range with start date only",
|
||||
fields: fields{
|
||||
FilterBy: []string{"start_date"},
|
||||
FilterValue: []string{"2018-12-12T07:33:20+00:00"},
|
||||
FilterComparator: []string{"greater"},
|
||||
Filter: "start_date > '2018-12-12T07:33:20+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ReadAll Tasks with range with start date only and greater equals",
|
||||
name: "ReadAll Tasks with range with start date only between",
|
||||
fields: fields{
|
||||
FilterBy: []string{"start_date"},
|
||||
FilterValue: []string{"2018-12-12T07:33:20+00:00"},
|
||||
FilterComparator: []string{"greater_equals"},
|
||||
Filter: "start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -843,12 +835,35 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ReadAll Tasks with range with start date only and greater equals",
|
||||
fields: fields{
|
||||
Filter: "start_date >= '2018-12-12T07:33:20+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task7,
|
||||
task9,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "range and nesting",
|
||||
fields: fields{
|
||||
Filter: "(start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00') || end_date > '2018-12-13T00:00:00+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task7,
|
||||
task8,
|
||||
task9,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "undone tasks only",
|
||||
fields: fields{
|
||||
FilterBy: []string{"done"},
|
||||
FilterValue: []string{"false"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "done = false",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -892,9 +907,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "done tasks only",
|
||||
fields: fields{
|
||||
FilterBy: []string{"done"},
|
||||
FilterValue: []string{"true"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "done = true",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -905,9 +918,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "done tasks only - not equals done",
|
||||
fields: fields{
|
||||
FilterBy: []string{"done"},
|
||||
FilterValue: []string{"false"},
|
||||
FilterComparator: []string{"not_equals"},
|
||||
Filter: "done != false",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -918,10 +929,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "range with nulls",
|
||||
fields: fields{
|
||||
FilterBy: []string{"start_date", "end_date"},
|
||||
FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
|
||||
FilterComparator: []string{"greater", "less"},
|
||||
FilterIncludeNulls: true,
|
||||
Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -976,9 +985,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filtered with like",
|
||||
fields: fields{
|
||||
FilterBy: []string{"title"},
|
||||
FilterValue: []string{"with"},
|
||||
FilterComparator: []string{"like"},
|
||||
Filter: "title ~ with",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task7,
|
||||
task8,
|
||||
task9,
|
||||
task27,
|
||||
task28,
|
||||
task29,
|
||||
task30,
|
||||
task31,
|
||||
task33,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filtered with like and '",
|
||||
fields: fields{
|
||||
Filter: "title ~ 'with'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -997,9 +1023,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filtered reminder dates",
|
||||
fields: fields{
|
||||
FilterBy: []string{"reminders", "reminders"},
|
||||
FilterValue: []string{"2018-10-01T00:00:00+00:00", "2018-12-10T00:00:00+00:00"},
|
||||
FilterComparator: []string{"greater", "less"},
|
||||
Filter: "reminders > '2018-10-01T00:00:00+00:00' && reminders < '2018-12-10T00:00:00+00:00'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1008,12 +1032,22 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter in keyword",
|
||||
fields: fields{
|
||||
Filter: "id in '1,2,34'", // user does not have permission to access task 34
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task1,
|
||||
task2,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter in",
|
||||
fields: fields{
|
||||
FilterBy: []string{"id"},
|
||||
FilterValue: []string{"1,2,34"}, // Task 34 is forbidden for user 1
|
||||
FilterComparator: []string{"in"},
|
||||
Filter: "id ?= '1,2,34'", // user does not have permission to access task 34
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1025,9 +1059,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by username",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees"},
|
||||
FilterValue: []string{"user1"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "assignees = 'user1'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1038,9 +1070,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by username with users field name",
|
||||
fields: fields{
|
||||
FilterBy: []string{"users"},
|
||||
FilterValue: []string{"user1"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "users = 'user1'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: nil,
|
||||
|
@ -1049,9 +1079,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by username with user_id field name",
|
||||
fields: fields{
|
||||
FilterBy: []string{"user_id"},
|
||||
FilterValue: []string{"user1"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "user_id = 'user1'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: nil,
|
||||
|
@ -1060,9 +1088,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by multiple username",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees", "assignees"},
|
||||
FilterValue: []string{"user1", "user2"},
|
||||
FilterComparator: []string{"equals", "equals"},
|
||||
Filter: "assignees = 'user1' || assignees = 'user2'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1074,9 +1100,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by numbers",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees"},
|
||||
FilterValue: []string{"1"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "assignees = 1",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{},
|
||||
|
@ -1085,20 +1109,51 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees by name with like",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees"},
|
||||
FilterValue: []string{"user"},
|
||||
FilterComparator: []string{"like"},
|
||||
Filter: "assignees ~ 'user'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
// Same as without any filter since the filter is ignored
|
||||
task1,
|
||||
task2,
|
||||
task3,
|
||||
task4,
|
||||
task5,
|
||||
task6,
|
||||
task7,
|
||||
task8,
|
||||
task9,
|
||||
task10,
|
||||
task11,
|
||||
task12,
|
||||
task15,
|
||||
task16,
|
||||
task17,
|
||||
task18,
|
||||
task19,
|
||||
task20,
|
||||
task21,
|
||||
task22,
|
||||
task23,
|
||||
task24,
|
||||
task25,
|
||||
task26,
|
||||
task27,
|
||||
task28,
|
||||
task29,
|
||||
task30,
|
||||
task31,
|
||||
task32,
|
||||
task33,
|
||||
task35,
|
||||
task39,
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter assignees in by id",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees"},
|
||||
FilterValue: []string{"1,2"},
|
||||
FilterComparator: []string{"in"},
|
||||
Filter: "assignees ?= '1,2'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{},
|
||||
|
@ -1107,9 +1162,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter assignees in by username",
|
||||
fields: fields{
|
||||
FilterBy: []string{"assignees"},
|
||||
FilterValue: []string{"user1,user2"},
|
||||
FilterComparator: []string{"in"},
|
||||
Filter: "assignees ?= 'user1,user2'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1121,9 +1174,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
{
|
||||
name: "filter labels",
|
||||
fields: fields{
|
||||
FilterBy: []string{"labels"},
|
||||
FilterValue: []string{"4"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "labels = 4",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1134,11 +1185,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter project",
|
||||
name: "filter project_id",
|
||||
fields: fields{
|
||||
FilterBy: []string{"project_id"},
|
||||
FilterValue: []string{"6"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "project_id = 6",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1146,13 +1195,31 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter project",
|
||||
fields: fields{
|
||||
Filter: "project = 6",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task15,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter project forbidden",
|
||||
fields: fields{
|
||||
Filter: "project_id = 20", // user1 has no access to project 20
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{},
|
||||
wantErr: false,
|
||||
},
|
||||
// TODO filter parent project?
|
||||
{
|
||||
name: "filter by index",
|
||||
fields: fields{
|
||||
FilterBy: []string{"index"},
|
||||
FilterValue: []string{"5"},
|
||||
FilterComparator: []string{"equals"},
|
||||
Filter: "index = 5",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
|
@ -1321,6 +1388,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
task9,
|
||||
},
|
||||
},
|
||||
// TODO unix dates
|
||||
// TODO date magic
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
@ -1334,11 +1403,10 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
SortBy: tt.fields.SortBy,
|
||||
OrderBy: tt.fields.OrderBy,
|
||||
|
||||
FilterBy: tt.fields.FilterBy,
|
||||
FilterValue: tt.fields.FilterValue,
|
||||
FilterComparator: tt.fields.FilterComparator,
|
||||
FilterIncludeNulls: tt.fields.FilterIncludeNulls,
|
||||
|
||||
Filter: tt.fields.Filter,
|
||||
|
||||
CRUDable: tt.fields.CRUDable,
|
||||
Rights: tt.fields.Rights,
|
||||
}
|
||||
|
|
|
@ -76,23 +76,21 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error)
|
|||
return
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
|
||||
func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (filterCond builder.Cond, err error) {
|
||||
|
||||
orderby, err := getOrderByDBStatement(opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Some filters need a special treatment since they are in a separate table
|
||||
reminderFilters := []builder.Cond{}
|
||||
assigneeFilters := []builder.Cond{}
|
||||
labelFilters := []builder.Cond{}
|
||||
projectFilters := []builder.Cond{}
|
||||
|
||||
var filters = make([]builder.Cond, 0, len(opts.filters))
|
||||
var dbFilters = make([]builder.Cond, 0, len(rawFilters))
|
||||
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
|
||||
for _, f := range opts.filters {
|
||||
for _, f := range rawFilters {
|
||||
|
||||
if nested, is := f.value.([]*taskFilter); is {
|
||||
nestedDBFilters, err := convertFiltersToDBFilterCond(nested, includeNulls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbFilters = append(dbFilters, nestedDBFilters)
|
||||
continue
|
||||
}
|
||||
|
||||
if f.field == "reminders" {
|
||||
filter, err := getFilterCond(&taskFilter{
|
||||
// recreating the struct here to avoid modifying it when reusing the opts struct
|
||||
|
@ -100,17 +98,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
value: f.value,
|
||||
comparator: f.comparator,
|
||||
isNumeric: f.isNumeric,
|
||||
}, opts.filterIncludeNulls)
|
||||
}, includeNulls)
|
||||
if err != nil {
|
||||
return nil, totalCount, err
|
||||
return nil, err
|
||||
}
|
||||
reminderFilters = append(reminderFilters, filter)
|
||||
dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_reminders", filter))
|
||||
continue
|
||||
}
|
||||
|
||||
if f.field == "assignees" {
|
||||
if f.comparator == taskFilterComparatorLike {
|
||||
return nil, totalCount, err
|
||||
return
|
||||
}
|
||||
filter, err := getFilterCond(&taskFilter{
|
||||
// recreating the struct here to avoid modifying it when reusing the opts struct
|
||||
|
@ -118,11 +116,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
value: f.value,
|
||||
comparator: f.comparator,
|
||||
isNumeric: f.isNumeric,
|
||||
}, opts.filterIncludeNulls)
|
||||
}, includeNulls)
|
||||
if err != nil {
|
||||
return nil, totalCount, err
|
||||
return nil, err
|
||||
}
|
||||
assigneeFilters = append(assigneeFilters, filter)
|
||||
|
||||
assigneeFilter := builder.In("user_id",
|
||||
builder.Select("id").
|
||||
From("users").
|
||||
Where(filter),
|
||||
)
|
||||
dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_assignees", assigneeFilter))
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -133,11 +137,12 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
value: f.value,
|
||||
comparator: f.comparator,
|
||||
isNumeric: f.isNumeric,
|
||||
}, opts.filterIncludeNulls)
|
||||
}, includeNulls)
|
||||
if err != nil {
|
||||
return nil, totalCount, err
|
||||
return nil, err
|
||||
}
|
||||
labelFilters = append(labelFilters, filter)
|
||||
|
||||
dbFilters = append(dbFilters, getFilterCondForSeparateTable("label_tasks", filter))
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -148,19 +153,60 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
value: f.value,
|
||||
comparator: f.comparator,
|
||||
isNumeric: f.isNumeric,
|
||||
}, opts.filterIncludeNulls)
|
||||
}, includeNulls)
|
||||
if err != nil {
|
||||
return nil, totalCount, err
|
||||
return nil, err
|
||||
}
|
||||
projectFilters = append(projectFilters, filter)
|
||||
|
||||
cond := builder.In(
|
||||
"project_id",
|
||||
builder.
|
||||
Select("id").
|
||||
From("projects").
|
||||
Where(filter),
|
||||
)
|
||||
dbFilters = append(dbFilters, cond)
|
||||
continue
|
||||
}
|
||||
|
||||
filter, err := getFilterCond(f, opts.filterIncludeNulls)
|
||||
filter, err := getFilterCond(f, includeNulls)
|
||||
if err != nil {
|
||||
return nil, totalCount, err
|
||||
return nil, err
|
||||
}
|
||||
filters = append(filters, filter)
|
||||
dbFilters = append(dbFilters, filter)
|
||||
}
|
||||
|
||||
if len(dbFilters) > 0 {
|
||||
if len(dbFilters) == 1 {
|
||||
filterCond = dbFilters[0]
|
||||
} else {
|
||||
for i, f := range dbFilters {
|
||||
if len(dbFilters) > i+1 {
|
||||
switch rawFilters[i+1].join {
|
||||
case filterConcatOr:
|
||||
filterCond = builder.Or(filterCond, f, dbFilters[i+1])
|
||||
case filterConcatAnd:
|
||||
filterCond = builder.And(filterCond, f, dbFilters[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filterCond, nil
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
|
||||
|
||||
orderby, err := getOrderByDBStatement(opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
filterCond, err := convertFiltersToDBFilterCond(opts.parsedFilters, opts.filterIncludeNulls)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Then return all tasks for that projects
|
||||
|
@ -199,53 +245,6 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
|||
favoritesCond = builder.In("id", favCond)
|
||||
}
|
||||
|
||||
if len(reminderFilters) > 0 {
|
||||
filters = append(filters, getFilterCondForSeparateTable("task_reminders", opts.filterConcat, reminderFilters))
|
||||
}
|
||||
|
||||
if len(assigneeFilters) > 0 {
|
||||
assigneeFilter := []builder.Cond{
|
||||
builder.In("user_id",
|
||||
builder.Select("id").
|
||||
From("users").
|
||||
Where(builder.Or(assigneeFilters...)),
|
||||
)}
|
||||
filters = append(filters, getFilterCondForSeparateTable("task_assignees", opts.filterConcat, assigneeFilter))
|
||||
}
|
||||
|
||||
if len(labelFilters) > 0 {
|
||||
filters = append(filters, getFilterCondForSeparateTable("label_tasks", opts.filterConcat, labelFilters))
|
||||
}
|
||||
|
||||
if len(projectFilters) > 0 {
|
||||
var filtercond builder.Cond
|
||||
if opts.filterConcat == filterConcatOr {
|
||||
filtercond = builder.Or(projectFilters...)
|
||||
}
|
||||
if opts.filterConcat == filterConcatAnd {
|
||||
filtercond = builder.And(projectFilters...)
|
||||
}
|
||||
|
||||
cond := builder.In(
|
||||
"project_id",
|
||||
builder.
|
||||
Select("id").
|
||||
From("projects").
|
||||
Where(filtercond),
|
||||
)
|
||||
filters = append(filters, cond)
|
||||
}
|
||||
|
||||
var filterCond builder.Cond
|
||||
if len(filters) > 0 {
|
||||
if opts.filterConcat == filterConcatOr {
|
||||
filterCond = builder.Or(filters...)
|
||||
}
|
||||
if opts.filterConcat == filterConcatAnd {
|
||||
filterCond = builder.And(filters...)
|
||||
}
|
||||
}
|
||||
|
||||
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
|
||||
cond := builder.And(builder.Or(projectIDCond, favoritesCond), where, filterCond)
|
||||
|
||||
|
@ -316,41 +315,23 @@ func convertFilterValues(value interface{}) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
|
||||
// Parsing and rebuilding the filter for Typesense has the advantage that we have more control over
|
||||
// what Typesense finally gets to see.
|
||||
func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string, err error) {
|
||||
|
||||
var sortbyFields []string
|
||||
for i, param := range opts.sortby {
|
||||
// Validate the params
|
||||
if err := param.validate(); err != nil {
|
||||
return nil, totalCount, err
|
||||
filters := []string{}
|
||||
|
||||
for _, f := range rawFilters {
|
||||
|
||||
if nested, is := f.value.([]*taskFilter); is {
|
||||
nestedDBFilters, err := convertParsedFilterToTypesense(nested)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
filters = append(filters, "("+nestedDBFilters+")")
|
||||
continue
|
||||
}
|
||||
|
||||
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
|
||||
if param.sortBy == "id" {
|
||||
param.sortBy = "created"
|
||||
}
|
||||
|
||||
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
|
||||
if i == 2 {
|
||||
// Typesense supports up to 3 sorting parameters
|
||||
// https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sortby := strings.Join(sortbyFields, ",")
|
||||
|
||||
projectIDStrings := []string{}
|
||||
for _, id := range opts.projectIDs {
|
||||
projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
|
||||
}
|
||||
filterBy := []string{
|
||||
"project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
|
||||
}
|
||||
|
||||
for _, f := range opts.filters {
|
||||
|
||||
if f.field == "reminders" {
|
||||
f.field = "reminders.reminder"
|
||||
}
|
||||
|
@ -363,6 +344,10 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
f.field = "labels.id"
|
||||
}
|
||||
|
||||
if f.field == "project" {
|
||||
f.field = "project_id"
|
||||
}
|
||||
|
||||
filter := f.field
|
||||
|
||||
switch f.comparator {
|
||||
|
@ -394,7 +379,67 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
|
|||
filter += "]"
|
||||
}
|
||||
|
||||
filterBy = append(filterBy, filter)
|
||||
filters = append(filters, filter)
|
||||
}
|
||||
|
||||
if len(filters) > 0 {
|
||||
if len(filters) == 1 {
|
||||
filterBy = filters[0]
|
||||
} else {
|
||||
for i, f := range filters {
|
||||
if len(filters) > i+1 {
|
||||
switch rawFilters[i+1].join {
|
||||
case filterConcatOr:
|
||||
filterBy = f + " || " + filters[i+1]
|
||||
case filterConcatAnd:
|
||||
filterBy = f + " && " + filters[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
|
||||
|
||||
var sortbyFields []string
|
||||
for i, param := range opts.sortby {
|
||||
// Validate the params
|
||||
if err := param.validate(); err != nil {
|
||||
return nil, totalCount, err
|
||||
}
|
||||
|
||||
// Typesense does not allow sorting by ID, so we sort by created timestamp instead
|
||||
if param.sortBy == "id" {
|
||||
param.sortBy = "created"
|
||||
}
|
||||
|
||||
sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
|
||||
|
||||
if i == 2 {
|
||||
// Typesense supports up to 3 sorting parameters
|
||||
// https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sortby := strings.Join(sortbyFields, ",")
|
||||
|
||||
projectIDStrings := []string{}
|
||||
for _, id := range opts.projectIDs {
|
||||
projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
|
||||
}
|
||||
|
||||
filter, err := convertParsedFilterToTypesense(opts.parsedFilters)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
filterBy := []string{
|
||||
"project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
|
||||
"(" + filter + ")",
|
||||
}
|
||||
|
||||
////////////////
|
||||
|
|
|
@ -162,8 +162,8 @@ func (t *Task) GetFrontendURL() string {
|
|||
type taskFilterConcatinator string
|
||||
|
||||
const (
|
||||
filterConcatAnd = "and"
|
||||
filterConcatOr = "or"
|
||||
filterConcatAnd taskFilterConcatinator = "and"
|
||||
filterConcatOr taskFilterConcatinator = "or"
|
||||
)
|
||||
|
||||
type taskSearchOptions struct {
|
||||
|
@ -171,9 +171,9 @@ type taskSearchOptions struct {
|
|||
page int
|
||||
perPage int
|
||||
sortby []*sortParam
|
||||
filters []*taskFilter
|
||||
filterConcat taskFilterConcatinator
|
||||
parsedFilters []*taskFilter
|
||||
filterIncludeNulls bool
|
||||
filter string
|
||||
projectIDs []int64
|
||||
}
|
||||
|
||||
|
@ -238,21 +238,13 @@ func getFilterCond(f *taskFilter, includeNulls bool) (cond builder.Cond, err err
|
|||
return
|
||||
}
|
||||
|
||||
func getFilterCondForSeparateTable(table string, concat taskFilterConcatinator, conds []builder.Cond) builder.Cond {
|
||||
var filtercond builder.Cond
|
||||
if concat == filterConcatOr {
|
||||
filtercond = builder.Or(conds...)
|
||||
}
|
||||
if concat == filterConcatAnd {
|
||||
filtercond = builder.And(conds...)
|
||||
}
|
||||
|
||||
func getFilterCondForSeparateTable(table string, cond builder.Cond) builder.Cond {
|
||||
return builder.In(
|
||||
"id",
|
||||
builder.
|
||||
Select("task_id").
|
||||
From(table).
|
||||
Where(filtercond),
|
||||
Where(cond),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -273,11 +265,6 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op
|
|||
return nil, 0, 0, nil
|
||||
}
|
||||
|
||||
// Set the default concatinator of filter variables to or if none was provided
|
||||
if opts.filterConcat == "" {
|
||||
opts.filterConcat = filterConcatOr
|
||||
}
|
||||
|
||||
// Get all project IDs and get the tasks
|
||||
opts.projectIDs = []int64{}
|
||||
var hasFavoritesProject bool
|
||||
|
|
|
@ -19,6 +19,7 @@ package models
|
|||
import (
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
|
@ -53,6 +54,12 @@ type Team struct {
|
|||
// A timestamp when this relation was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated" json:"updated"`
|
||||
|
||||
// Defines wether the team should be publicly discoverable when sharing a project
|
||||
IsPublic bool `xorm:"not null default false" json:"is_public"`
|
||||
|
||||
// Query parameter controlling whether to include public projects or not
|
||||
IncludePublic bool `xorm:"-" query:"include_public" json:"include_public"`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Rights `xorm:"-" json:"-"`
|
||||
}
|
||||
|
@ -100,6 +107,7 @@ type OIDCTeam struct {
|
|||
Name string
|
||||
OidcID string
|
||||
Description string
|
||||
IsPublic bool
|
||||
}
|
||||
|
||||
// GetTeamByID gets a team by its ID
|
||||
|
@ -287,11 +295,24 @@ func (t *Team) ReadAll(s *xorm.Session, a web.Auth, search string, page int, per
|
|||
|
||||
limit, start := getLimitFromPageIndex(page, perPage)
|
||||
all := []*Team{}
|
||||
|
||||
query := s.Select("teams.*").
|
||||
Table("teams").
|
||||
Join("INNER", "team_members", "team_members.team_id = teams.id").
|
||||
Where("team_members.user_id = ?", a.GetID()).
|
||||
Where(db.ILIKE("teams.name", search))
|
||||
|
||||
// If public teams are enabled, we want to include them in the result
|
||||
if config.ServiceEnablePublicTeams.GetBool() && t.IncludePublic {
|
||||
query = query.Where(
|
||||
builder.Or(
|
||||
builder.Eq{"teams.is_public": true},
|
||||
builder.Eq{"team_members.user_id": a.GetID()},
|
||||
),
|
||||
)
|
||||
} else {
|
||||
query = query.Where("team_members.user_id = ?", a.GetID())
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit, start)
|
||||
}
|
||||
|
@ -398,7 +419,7 @@ func (t *Team) Update(s *xorm.Session, _ web.Auth) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
_, err = s.ID(t.ID).Update(t)
|
||||
_, err = s.ID(t.ID).UseBool("is_public").Update(t)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
|
@ -49,6 +50,7 @@ func TestTeam_Create(t *testing.T) {
|
|||
"id": team.ID,
|
||||
"name": "Testteam293",
|
||||
"description": "Lorem Ispum",
|
||||
"is_public": false,
|
||||
}, false)
|
||||
})
|
||||
t.Run("empty name", func(t *testing.T) {
|
||||
|
@ -61,6 +63,27 @@ func TestTeam_Create(t *testing.T) {
|
|||
require.Error(t, err)
|
||||
assert.True(t, IsErrTeamNameCannotBeEmpty(err))
|
||||
})
|
||||
t.Run("public", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
team := &Team{
|
||||
Name: "Testteam293_Public",
|
||||
Description: "Lorem Ispum",
|
||||
IsPublic: true,
|
||||
}
|
||||
err := team.Create(s, doer)
|
||||
require.NoError(t, err)
|
||||
err = s.Commit()
|
||||
require.NoError(t, err)
|
||||
db.AssertExists(t, "teams", map[string]interface{}{
|
||||
"id": team.ID,
|
||||
"name": "Testteam293_Public",
|
||||
"description": "Lorem Ispum",
|
||||
"is_public": true,
|
||||
}, false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTeam_ReadOne(t *testing.T) {
|
||||
|
@ -126,6 +149,58 @@ func TestTeam_ReadAll(t *testing.T) {
|
|||
assert.Len(t, ts, 1)
|
||||
assert.Equal(t, int64(2), ts[0].ID)
|
||||
})
|
||||
t.Run("public discovery disabled", func(t *testing.T) {
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
team := &Team{}
|
||||
|
||||
// Default setting is having ServiceEnablePublicTeams disabled
|
||||
// In this default case, fetching teams with or without public flag should return the same result
|
||||
|
||||
// Fetch without public flag
|
||||
teams, _, _, err := team.ReadAll(s, doer, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reflect.Slice, reflect.TypeOf(teams).Kind())
|
||||
ts := teams.([]*Team)
|
||||
assert.Len(t, ts, 5)
|
||||
|
||||
// Fetch with public flag
|
||||
team.IncludePublic = true
|
||||
teams, _, _, err = team.ReadAll(s, doer, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reflect.Slice, reflect.TypeOf(teams).Kind())
|
||||
ts = teams.([]*Team)
|
||||
assert.Len(t, ts, 5)
|
||||
})
|
||||
|
||||
t.Run("public discovery enabled", func(t *testing.T) {
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
team := &Team{}
|
||||
|
||||
// Enable ServiceEnablePublicTeams feature
|
||||
config.ServiceEnablePublicTeams.Set(true)
|
||||
|
||||
// Fetch without public flag should be the same as before
|
||||
team.IncludePublic = false
|
||||
teams, _, _, err := team.ReadAll(s, doer, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reflect.Slice, reflect.TypeOf(teams).Kind())
|
||||
ts := teams.([]*Team)
|
||||
assert.Len(t, ts, 5)
|
||||
|
||||
// Fetch with public flag should return more teams
|
||||
team.IncludePublic = true
|
||||
teams, _, _, err = team.ReadAll(s, doer, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reflect.Slice, reflect.TypeOf(teams).Kind())
|
||||
ts = teams.([]*Team)
|
||||
assert.Len(t, ts, 7)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTeam_Update(t *testing.T) {
|
||||
|
|
|
@ -220,7 +220,7 @@ func HandleCallback(c echo.Context) error {
|
|||
log.Errorf("Could not proceed with group routine %v", err)
|
||||
}
|
||||
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
|
||||
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
|
||||
err = RemoveUserFromTeamsByIDs(s, u, teamIDsToLeave)
|
||||
if err != nil {
|
||||
log.Errorf("Found error while leaving teams %v", err)
|
||||
}
|
||||
|
@ -280,7 +280,7 @@ func RemoveEmptySSOTeams(s *xorm.Session, teamIDs []int64) (errs []error) {
|
|||
return errs
|
||||
}
|
||||
|
||||
func RemoveUserFromTeamsByIds(s *xorm.Session, u *user.User, teamIDs []int64) (err error) {
|
||||
func RemoveUserFromTeamsByIDs(s *xorm.Session, u *user.User, teamIDs []int64) (err error) {
|
||||
|
||||
if len(teamIDs) < 1 {
|
||||
return nil
|
||||
|
@ -298,14 +298,27 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
|
|||
var name string
|
||||
var description string
|
||||
var oidcID string
|
||||
var IsPublic bool
|
||||
|
||||
// Read name
|
||||
_, exists := team["name"]
|
||||
if exists {
|
||||
name = team["name"].(string)
|
||||
}
|
||||
|
||||
// Read description
|
||||
_, exists = team["description"]
|
||||
if exists {
|
||||
description = team["description"].(string)
|
||||
}
|
||||
|
||||
// Read isPublic flag
|
||||
_, exists = team["isPublic"]
|
||||
if exists {
|
||||
IsPublic = team["isPublic"].(bool)
|
||||
}
|
||||
|
||||
// Read oidcID
|
||||
_, exists = team["oidcID"]
|
||||
if exists {
|
||||
switch t := team["oidcID"].(type) {
|
||||
|
@ -324,7 +337,7 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
|
|||
errs = append(errs, &user.ErrOpenIDCustomScopeMalformed{})
|
||||
continue
|
||||
}
|
||||
teamData = append(teamData, &models.OIDCTeam{Name: name, OidcID: oidcID, Description: description})
|
||||
teamData = append(teamData, &models.OIDCTeam{Name: name, OidcID: oidcID, Description: description, IsPublic: IsPublic})
|
||||
}
|
||||
return teamData, errs
|
||||
}
|
||||
|
@ -339,6 +352,7 @@ func CreateOIDCTeam(s *xorm.Session, teamData *models.OIDCTeam, u *user.User, is
|
|||
Description: teamData.Description,
|
||||
OidcID: teamData.OidcID,
|
||||
Issuer: issuer,
|
||||
IsPublic: teamData.IsPublic,
|
||||
}
|
||||
err = team.CreateNewTeam(s, u, false)
|
||||
return team, err
|
||||
|
@ -363,12 +377,24 @@ func GetOrCreateTeamsByOIDC(s *xorm.Session, teamData []*models.OIDCTeam, u *use
|
|||
continue
|
||||
}
|
||||
|
||||
// Compare the name and update if it changed
|
||||
if team.Name != getOIDCTeamName(oidcTeam.Name) {
|
||||
team.Name = getOIDCTeamName(oidcTeam.Name)
|
||||
err = team.Update(s, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the description and update if it changed
|
||||
if team.Description != oidcTeam.Description {
|
||||
team.Description = oidcTeam.Description
|
||||
}
|
||||
|
||||
// Compare the isPublic flag and update if it changed
|
||||
if team.IsPublic != oidcTeam.IsPublic {
|
||||
team.IsPublic = oidcTeam.IsPublic
|
||||
}
|
||||
|
||||
err = team.Update(s, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("Team with oidc_id %v and name %v already exists.", team.OidcID, team.Name)
|
||||
|
|
|
@ -128,8 +128,41 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
"email": cl.Email,
|
||||
}, false)
|
||||
db.AssertExists(t, "teams", map[string]interface{}{
|
||||
"id": oidcTeams,
|
||||
"name": team + " (OIDC)",
|
||||
"id": oidcTeams,
|
||||
"name": team + " (OIDC)",
|
||||
"is_public": false,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("Update IsPublic flag for existing team", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
team := "testteam15"
|
||||
oidcID := "15"
|
||||
cl := &claims{
|
||||
Email: "other-email-address@some.service.com",
|
||||
VikunjaGroups: []map[string]interface{}{
|
||||
{"name": team, "oidcID": oidcID, "isPublic": true},
|
||||
},
|
||||
}
|
||||
|
||||
u, err := getOrCreateUser(s, cl, "https://some.service.com", "12345")
|
||||
require.NoError(t, err)
|
||||
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
|
||||
for _, err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
|
||||
require.NoError(t, err)
|
||||
err = s.Commit()
|
||||
require.NoError(t, err)
|
||||
|
||||
db.AssertExists(t, "teams", map[string]interface{}{
|
||||
"id": oidcTeams,
|
||||
"name": team + " (OIDC)",
|
||||
"is_public": true,
|
||||
}, false)
|
||||
})
|
||||
|
||||
|
@ -185,7 +218,7 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
|
||||
require.NoError(t, err)
|
||||
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
|
||||
err = RemoveUserFromTeamsByIDs(s, u, teamIDsToLeave)
|
||||
require.NoError(t, err)
|
||||
errs = RemoveEmptySSOTeams(s, teamIDsToLeave)
|
||||
for _, err = range errs {
|
||||
|
@ -226,7 +259,7 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
teamIDsToLeave := utils.NotIn(oldOidcTeams, oidcTeams)
|
||||
require.NoError(t, err)
|
||||
err = RemoveUserFromTeamsByIds(s, u, teamIDsToLeave)
|
||||
err = RemoveUserFromTeamsByIDs(s, u, teamIDsToLeave)
|
||||
require.NoError(t, err)
|
||||
errs = RemoveEmptySSOTeams(s, teamIDsToLeave)
|
||||
for _, err := range errs {
|
||||
|
|
|
@ -51,6 +51,7 @@ type vikunjaInfos struct {
|
|||
TaskCommentsEnabled bool `json:"task_comments_enabled"`
|
||||
DemoModeEnabled bool `json:"demo_mode_enabled"`
|
||||
WebhooksEnabled bool `json:"webhooks_enabled"`
|
||||
PublicTeamsEnabled bool `json:"public_teams_enabled"`
|
||||
}
|
||||
|
||||
type authInfo struct {
|
||||
|
@ -95,6 +96,7 @@ func Info(c echo.Context) error {
|
|||
TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(),
|
||||
DemoModeEnabled: config.ServiceDemoMode.GetBool(),
|
||||
WebhooksEnabled: config.WebhooksEnabled.GetBool(),
|
||||
PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
|
||||
AvailableMigrators: []string{
|
||||
(&vikunja_file.FileMigrator{}).Name(),
|
||||
(&ticktick.Migrator{}).Name(),
|
||||
|
|
|
@ -48,7 +48,7 @@ func SetupTokenMiddleware() echo.MiddlewareFunc {
|
|||
|
||||
return false
|
||||
},
|
||||
ErrorHandler: func(c echo.Context, err error) error {
|
||||
ErrorHandler: func(_ echo.Context, err error) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing, malformed, expired or otherwise invalid token provided").SetInternal(err)
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ func setupMetrics(a *echo.Group) {
|
|||
r := a.Group("/metrics")
|
||||
|
||||
if config.MetricsUsername.GetString() != "" && config.MetricsPassword.GetString() != "" {
|
||||
r.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||
r.Use(middleware.BasicAuth(func(username, password string, _ echo.Context) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte(config.MetricsUsername.GetString())) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(password), []byte(config.MetricsPassword.GetString())) == 1 {
|
||||
return true, nil
|
||||
|
|
|
@ -203,7 +203,7 @@ func RegisterRoutes(e *echo.Echo) {
|
|||
|
||||
// API Routes
|
||||
a := e.Group("/api/v1")
|
||||
e.OnAddRouteHandler = func(host string, route echo.Route, handler echo.HandlerFunc, middleware []echo.MiddlewareFunc) {
|
||||
e.OnAddRouteHandler = func(_ string, route echo.Route, _ echo.HandlerFunc, _ []echo.MiddlewareFunc) {
|
||||
models.CollectRoutesForAPITokenUsage(route)
|
||||
}
|
||||
registerAPIRoutes(a)
|
||||
|
|
|
@ -1937,26 +1937,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
|
||||
"name": "filter_by",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The value to filter for.",
|
||||
"name": "filter_value",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The comparator to use for a filter. Available values are ` + "`" + `equals` + "`" + `, ` + "`" + `greater` + "`" + `, ` + "`" + `greater_equals` + "`" + `, ` + "`" + `less` + "`" + `, ` + "`" + `less_equals` + "`" + `, ` + "`" + `like` + "`" + ` and ` + "`" + `in` + "`" + `. ` + "`" + `in` + "`" + ` expects comma-separated values in ` + "`" + `filter_value` + "`" + `. Defaults to ` + "`" + `equals` + "`" + `",
|
||||
"name": "filter_comparator",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The concatinator to use for filters. Available values are ` + "`" + `and` + "`" + ` or ` + "`" + `or` + "`" + `. Defaults to ` + "`" + `or` + "`" + `.",
|
||||
"name": "filter_concat",
|
||||
"description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
|
@ -2169,26 +2151,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
|
||||
"name": "filter_by",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like ` + "`" + `due_date` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, etc.",
|
||||
"name": "filter_value",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The comparator to use for a filter. Available values are ` + "`" + `equals` + "`" + `, ` + "`" + `greater` + "`" + `, ` + "`" + `greater_equals` + "`" + `, ` + "`" + `less` + "`" + `, ` + "`" + `less_equals` + "`" + `, ` + "`" + `like` + "`" + ` and ` + "`" + `in` + "`" + `. ` + "`" + `in` + "`" + ` expects comma-separated values in ` + "`" + `filter_value` + "`" + `. Defaults to ` + "`" + `equals` + "`" + `",
|
||||
"name": "filter_comparator",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The concatinator to use for filters. Available values are ` + "`" + `and` + "`" + ` or ` + "`" + `or` + "`" + `. Defaults to ` + "`" + `or` + "`" + `.",
|
||||
"name": "filter_concat",
|
||||
"description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
|
@ -8132,35 +8096,13 @@ const docTemplate = `{
|
|||
"models.TaskCollection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter_by": {
|
||||
"description": "The field name of the field to filter by",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter_comparator": {
|
||||
"description": "The comparator for field and value",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter_concat": {
|
||||
"description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter_include_nulls": {
|
||||
"description": "If set to true, the result will also include null values",
|
||||
"type": "boolean"
|
||||
},
|
||||
"filter_value": {
|
||||
"description": "The value of the field name to filter by",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"order_by": {
|
||||
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
|
||||
"type": "array",
|
||||
|
@ -8287,6 +8229,14 @@ const docTemplate = `{
|
|||
"description": "The unique, numeric id of this team.",
|
||||
"type": "integer"
|
||||
},
|
||||
"include_public": {
|
||||
"description": "Query parameter controlling whether to include public projects or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"description": "Defines wether the team should be publicly discoverable when sharing a project",
|
||||
"type": "boolean"
|
||||
},
|
||||
"members": {
|
||||
"description": "An array of all members in this team.",
|
||||
"type": "array",
|
||||
|
@ -8422,6 +8372,14 @@ const docTemplate = `{
|
|||
"description": "The unique, numeric id of this team.",
|
||||
"type": "integer"
|
||||
},
|
||||
"include_public": {
|
||||
"description": "Query parameter controlling whether to include public projects or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"description": "Defines wether the team should be publicly discoverable when sharing a project",
|
||||
"type": "boolean"
|
||||
},
|
||||
"members": {
|
||||
"description": "An array of all members in this team.",
|
||||
"type": "array",
|
||||
|
@ -8944,6 +8902,9 @@ const docTemplate = `{
|
|||
"motd": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_teams_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"registration_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
@ -1929,26 +1929,8 @@
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
|
||||
"name": "filter_by",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The value to filter for.",
|
||||
"name": "filter_value",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`",
|
||||
"name": "filter_comparator",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`.",
|
||||
"name": "filter_concat",
|
||||
"description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
|
@ -2161,26 +2143,8 @@
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
|
||||
"name": "filter_by",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc.",
|
||||
"name": "filter_value",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`",
|
||||
"name": "filter_comparator",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`.",
|
||||
"name": "filter_concat",
|
||||
"description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
|
@ -8124,35 +8088,13 @@
|
|||
"models.TaskCollection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter_by": {
|
||||
"description": "The field name of the field to filter by",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter_comparator": {
|
||||
"description": "The comparator for field and value",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter_concat": {
|
||||
"description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter_include_nulls": {
|
||||
"description": "If set to true, the result will also include null values",
|
||||
"type": "boolean"
|
||||
},
|
||||
"filter_value": {
|
||||
"description": "The value of the field name to filter by",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"order_by": {
|
||||
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
|
||||
"type": "array",
|
||||
|
@ -8279,6 +8221,14 @@
|
|||
"description": "The unique, numeric id of this team.",
|
||||
"type": "integer"
|
||||
},
|
||||
"include_public": {
|
||||
"description": "Query parameter controlling whether to include public projects or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"description": "Defines wether the team should be publicly discoverable when sharing a project",
|
||||
"type": "boolean"
|
||||
},
|
||||
"members": {
|
||||
"description": "An array of all members in this team.",
|
||||
"type": "array",
|
||||
|
@ -8414,6 +8364,14 @@
|
|||
"description": "The unique, numeric id of this team.",
|
||||
"type": "integer"
|
||||
},
|
||||
"include_public": {
|
||||
"description": "Query parameter controlling whether to include public projects or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_public": {
|
||||
"description": "Defines wether the team should be publicly discoverable when sharing a project",
|
||||
"type": "boolean"
|
||||
},
|
||||
"members": {
|
||||
"description": "An array of all members in this team.",
|
||||
"type": "array",
|
||||
|
@ -8936,6 +8894,9 @@
|
|||
"motd": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_teams_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"registration_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
@ -783,28 +783,11 @@ definitions:
|
|||
type: object
|
||||
models.TaskCollection:
|
||||
properties:
|
||||
filter_by:
|
||||
description: The field name of the field to filter by
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
filter_comparator:
|
||||
description: The comparator for field and value
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
filter_concat:
|
||||
description: The way all filter conditions are concatenated together, can
|
||||
be either "and" or "or".,
|
||||
filter:
|
||||
type: string
|
||||
filter_include_nulls:
|
||||
description: If set to true, the result will also include null values
|
||||
type: boolean
|
||||
filter_value:
|
||||
description: The value of the field name to filter by
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
order_by:
|
||||
description: The query parameter to order the items by. This can be either
|
||||
asc or desc, with asc being the default.
|
||||
|
@ -894,6 +877,14 @@ definitions:
|
|||
id:
|
||||
description: The unique, numeric id of this team.
|
||||
type: integer
|
||||
include_public:
|
||||
description: Query parameter controlling whether to include public projects
|
||||
or not
|
||||
type: boolean
|
||||
is_public:
|
||||
description: Defines wether the team should be publicly discoverable when
|
||||
sharing a project
|
||||
type: boolean
|
||||
members:
|
||||
description: An array of all members in this team.
|
||||
items:
|
||||
|
@ -1001,6 +992,14 @@ definitions:
|
|||
id:
|
||||
description: The unique, numeric id of this team.
|
||||
type: integer
|
||||
include_public:
|
||||
description: Query parameter controlling whether to include public projects
|
||||
or not
|
||||
type: boolean
|
||||
is_public:
|
||||
description: Defines wether the team should be publicly discoverable when
|
||||
sharing a project
|
||||
type: boolean
|
||||
members:
|
||||
description: An array of all members in this team.
|
||||
items:
|
||||
|
@ -1386,6 +1385,8 @@ definitions:
|
|||
type: string
|
||||
motd:
|
||||
type: string
|
||||
public_teams_enabled:
|
||||
type: boolean
|
||||
registration_enabled:
|
||||
type: boolean
|
||||
task_attachments_enabled:
|
||||
|
@ -2718,27 +2719,10 @@ paths:
|
|||
in: query
|
||||
name: s
|
||||
type: string
|
||||
- description: The name of the field to filter by. Allowed values are all task
|
||||
properties. Task properties which are their own object require passing in
|
||||
the id of that entity. Accepts an array for multiple filters which will
|
||||
be chanied together, all supplied filter must match.
|
||||
- description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters
|
||||
for a full explanation of the feature.
|
||||
in: query
|
||||
name: filter_by
|
||||
type: string
|
||||
- description: The value to filter for.
|
||||
in: query
|
||||
name: filter_value
|
||||
type: string
|
||||
- description: The comparator to use for a filter. Available values are `equals`,
|
||||
`greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in`
|
||||
expects comma-separated values in `filter_value`. Defaults to `equals`
|
||||
in: query
|
||||
name: filter_comparator
|
||||
type: string
|
||||
- description: The concatinator to use for filters. Available values are `and`
|
||||
or `or`. Defaults to `or`.
|
||||
in: query
|
||||
name: filter_concat
|
||||
name: filter
|
||||
type: string
|
||||
- description: If set to true the result will include filtered fields whose
|
||||
value is set to `null`. Available values are `true` or `false`. Defaults
|
||||
|
@ -2885,30 +2869,10 @@ paths:
|
|||
in: query
|
||||
name: order_by
|
||||
type: string
|
||||
- description: The name of the field to filter by. Allowed values are all task
|
||||
properties. Task properties which are their own object require passing in
|
||||
the id of that entity. Accepts an array for multiple filters which will
|
||||
be chanied together, all supplied filter must match.
|
||||
- description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters
|
||||
for a full explanation of the feature.
|
||||
in: query
|
||||
name: filter_by
|
||||
type: string
|
||||
- description: The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)-
|
||||
or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style
|
||||
relative dates for all date fields like `due_date`, `start_date`, `end_date`,
|
||||
etc.
|
||||
in: query
|
||||
name: filter_value
|
||||
type: string
|
||||
- description: The comparator to use for a filter. Available values are `equals`,
|
||||
`greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in`
|
||||
expects comma-separated values in `filter_value`. Defaults to `equals`
|
||||
in: query
|
||||
name: filter_comparator
|
||||
type: string
|
||||
- description: The concatinator to use for filters. Available values are `and`
|
||||
or `or`. Defaults to `or`.
|
||||
in: query
|
||||
name: filter_concat
|
||||
name: filter
|
||||
type: string
|
||||
- description: If set to true the result will include filtered fields whose
|
||||
value is set to `null`. Available values are `true` or `false`. Defaults
|
||||
|
|
Loading…
Reference in New Issue