mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 12:12:15 +01:00
Compare commits
37 Commits
feature/vo
...
4ff8a72f0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff8a72f0b | ||
|
|
4790693017 | ||
|
|
3caf7438b5 | ||
|
|
d929d31847 | ||
|
|
d7bb36d50f | ||
|
|
b3785f0aa6 | ||
|
|
8e47f07f09 | ||
|
|
da611086e8 | ||
|
|
a220d0e592 | ||
|
|
0e2c4431a0 | ||
|
|
2f4c079f9f | ||
|
|
f826474f88 | ||
|
|
98bbe800f1 | ||
|
|
7035d5fd6e | ||
|
|
f32ec59bb5 | ||
|
|
d2b6be137f | ||
|
|
dc082b2b19 | ||
|
|
82ad8ee316 | ||
|
|
117c3c4b6c | ||
|
|
4c2586936d | ||
|
|
ca843168f6 | ||
|
|
67dcf77635 | ||
|
|
dcd21345b2 | ||
|
|
1f832a24a0 | ||
|
|
07cf3f7405 | ||
|
|
a880ccb32c | ||
|
|
5a41c356d4 | ||
|
|
72bddfba8b | ||
|
|
34a1a89c30 | ||
|
|
77e4d768d4 | ||
|
|
d42e3ffff0 | ||
|
|
4e26c8ad6d | ||
|
|
57794940f1 | ||
|
|
09827d3d83 | ||
|
|
64c5da5223 | ||
|
|
983e6c3815 | ||
|
|
f34b60874e |
@@ -1,54 +0,0 @@
|
|||||||
.git
|
|
||||||
**/.git
|
|
||||||
.gitmodules
|
|
||||||
**/.gitmodules
|
|
||||||
.github
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.log
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
k8s
|
|
||||||
docs
|
|
||||||
e2e
|
|
||||||
tests
|
|
||||||
|
|
||||||
docker-compose.yml
|
|
||||||
docker/local
|
|
||||||
|
|
||||||
.phpunit.cache
|
|
||||||
.phpunit.result.cache
|
|
||||||
coverage
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
blob-report
|
|
||||||
playwright/.cache
|
|
||||||
openapi.json
|
|
||||||
playwright
|
|
||||||
playwright.config.ts
|
|
||||||
vitest.config.ts
|
|
||||||
phpunit.xml
|
|
||||||
phpstan.neon
|
|
||||||
pint.json
|
|
||||||
eslint.config.mjs
|
|
||||||
tsconfig.json
|
|
||||||
jsconfig.json
|
|
||||||
postcss.config.js
|
|
||||||
tailwind.config.js
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
extensions/*/node_modules
|
|
||||||
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
auth.json
|
|
||||||
.env.backup
|
|
||||||
.rnd
|
|
||||||
|
|
||||||
_ide_helper.php
|
|
||||||
.phpstorm.meta.php
|
|
||||||
|
|
||||||
storage/logs/*
|
|
||||||
storage/*.key
|
|
||||||
23
.github/VOUCHED.td
vendored
23
.github/VOUCHED.td
vendored
@@ -1,23 +0,0 @@
|
|||||||
# Vouched contributors for solidtime.
|
|
||||||
#
|
|
||||||
# One handle per line, without the leading @, sorted alphabetically.
|
|
||||||
# Prefix a handle with - to denounce them, optionally followed by a reason.
|
|
||||||
# Format reference: https://github.com/mitchellh/vouch
|
|
||||||
#
|
|
||||||
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
|
|
||||||
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
|
|
||||||
# discussion and the vouch workflows will update this file.
|
|
||||||
#
|
|
||||||
# Collaborators with write access and bots are always allowed and do not need
|
|
||||||
# an entry here.
|
|
||||||
#
|
|
||||||
# Seeded 2026-07-25 from the authors of every merged pull request.
|
|
||||||
|
|
||||||
agross
|
|
||||||
candideu
|
|
||||||
KasparRosin
|
|
||||||
korridor
|
|
||||||
Onatcer
|
|
||||||
ShrootBuck
|
|
||||||
smileBeda
|
|
||||||
utlark
|
|
||||||
43
.github/workflows/build-onpremise.yml
vendored
43
.github/workflows/build-onpremise.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-onpremise.yml'
|
- '.github/workflows/build-onpremise.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -48,9 +46,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -93,23 +91,14 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
@@ -135,27 +124,27 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -174,7 +163,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -188,25 +177,25 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|||||||
47
.github/workflows/build-private.yml
vendored
47
.github/workflows/build-private.yml
vendored
@@ -8,8 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-private.yml'
|
- '.github/workflows/build-private.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'extensions/manifest.json'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -35,9 +33,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: version
|
id: version
|
||||||
@@ -70,27 +68,14 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Read extension manifest"
|
|
||||||
id: extension-manifest
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
|
|
||||||
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
|
|
||||||
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
|
|
||||||
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
|
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
repository: solidtime-io/extension-billing
|
||||||
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
|
||||||
path: extensions/Billing
|
path: extensions/Billing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||||
|
|
||||||
@@ -108,10 +93,9 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
repository: solidtime-io/extension-services
|
||||||
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
|
||||||
path: extensions/Services
|
path: extensions/Services
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||||
|
|
||||||
@@ -127,10 +111,9 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
repository: solidtime-io/extension-invoicing
|
||||||
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
@@ -177,7 +160,7 @@ jobs:
|
|||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
@@ -185,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
images: rg.fr-par.scw.cloud/solidtime/solidtime
|
||||||
tags: |
|
tags: |
|
||||||
@@ -196,13 +179,13 @@ jobs:
|
|||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push"
|
- name: "Build and push"
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
33
.github/workflows/build-public.yml
vendored
33
.github/workflows/build-public.yml
vendored
@@ -8,7 +8,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-public.yml'
|
- '.github/workflows/build-public.yml'
|
||||||
- '.dockerignore'
|
|
||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -48,9 +47,9 @@ jobs:
|
|||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
id: previoustag
|
id: previoustag
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
uses: "WyriHaximus/github-action-get-previous-tag@v2"
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
with:
|
with:
|
||||||
pattern: "v*[0-9].*[0-9].*[0-9]"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: release-version
|
||||||
@@ -93,7 +92,7 @@ jobs:
|
|||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -110,34 +109,34 @@ jobs:
|
|||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: "meta"
|
id: "meta"
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
${{ env.GHCR_REPO }}
|
${{ env.GHCR_REPO }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
- name: "Login to Docker Hub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push by digest"
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
@@ -156,7 +155,7 @@ jobs:
|
|||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: "Upload digest"
|
- name: "Upload digest"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -170,31 +169,31 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
- name: "Login to GHCR"
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKERHUB_REPO }}
|
${{ env.DOCKERHUB_REPO }}
|
||||||
|
|||||||
4
.github/workflows/generate-api-docs.yml
vendored
4
.github/workflows/generate-api-docs.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
run: php artisan scramble:export --path=build/api-docs.json
|
run: php artisan scramble:export --path=build/api-docs.json
|
||||||
|
|
||||||
- name: "Upload API docs to GitHub"
|
- name: "Upload API docs to GitHub"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: api-docs.json
|
name: api-docs.json
|
||||||
path: build/api-docs.json
|
path: build/api-docs.json
|
||||||
|
|||||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
4
.github/workflows/npm-test-unit.yml
vendored
4
.github/workflows/npm-test-unit.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: "Run composer install"
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v7.0.0
|
uses: codecov/codecov-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.6
|
uses: aglipanci/laravel-pint-action@2.6
|
||||||
|
|||||||
14
.github/workflows/playwright.yml
vendored
14
.github/workflows/playwright.yml
vendored
@@ -35,10 +35,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||||
|
|
||||||
- name: "Upload blob report"
|
- name: "Upload blob report"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
@@ -99,10 +99,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: "Download blob reports"
|
- name: "Download blob reports"
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: all-blob-reports
|
path: all-blob-reports
|
||||||
pattern: blob-report-*
|
pattern: blob-report-*
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
|
|
||||||
- name: "Upload merged HTML report"
|
- name: "Upload merged HTML report"
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
|
|||||||
75
.github/workflows/vouch-check-pr.yml
vendored
75
.github/workflows/vouch-check-pr.yml
vendored
@@ -1,75 +0,0 @@
|
|||||||
name: Vouch (check PR)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
(github.event_name == 'issue_comment' &&
|
|
||||||
github.event.issue.pull_request &&
|
|
||||||
contains(github.event.comment.body, '/recheck'))
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
|
|
||||||
- name: "Measure diff size"
|
|
||||||
id: size
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
|
|
||||||
# Changes to these files do not count towards the 50-line limit.
|
|
||||||
# One extended regex per line, matched against the whole repo-relative
|
|
||||||
# path, so use a leading .* to match a file in any directory.
|
|
||||||
IGNORED: |
|
|
||||||
package-lock\.json
|
|
||||||
composer\.lock
|
|
||||||
tests/.*
|
|
||||||
e2e/.*
|
|
||||||
.*\.(test|spec)\.(ts|js|vue)
|
|
||||||
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# An empty list yields "^()$", which matches no filename. grep exits
|
|
||||||
# 1 on an empty list, so swallow that rather than fail the step.
|
|
||||||
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
|
|
||||||
ignored="^($(join <<<"$IGNORED"))$"
|
|
||||||
|
|
||||||
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
|
|
||||||
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
|
|
||||||
awk -F'\t' -v ignored="$ignored" '
|
|
||||||
$1 ~ ignored { next }
|
|
||||||
{ n += $2 }
|
|
||||||
END { print n+0 }')
|
|
||||||
echo "total=$total" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Countable diff size: $total line(s)"
|
|
||||||
|
|
||||||
- name: "Small patch (denounced users still blocked)"
|
|
||||||
if: fromJSON(steps.size.outputs.total) <= 50
|
|
||||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
|
||||||
with:
|
|
||||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
auto-close: true
|
|
||||||
require-vouch: false
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Full vouch required"
|
|
||||||
if: fromJSON(steps.size.outputs.total) > 50
|
|
||||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
|
||||||
with:
|
|
||||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
auto-close: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Vouch (manage by discussion)
|
|
||||||
|
|
||||||
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
|
|
||||||
|
|
||||||
on:
|
|
||||||
discussion_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: vouch-manage
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
discussions: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
manage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Apply vouch command"
|
|
||||||
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
|
|
||||||
with:
|
|
||||||
discussion-number: ${{ github.event.discussion.number }}
|
|
||||||
comment-node-id: ${{ github.event.comment.node_id }}
|
|
||||||
roles: admin,maintain,write
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
35
.github/workflows/vouch-manage-by-issue.yml
vendored
35
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
name: Vouch (manage by issue)
|
|
||||||
|
|
||||||
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
|
|
||||||
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: vouch-manage
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
manage:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: "Apply vouch command"
|
|
||||||
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
|
|
||||||
with:
|
|
||||||
issue-id: ${{ github.event.issue.number }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
roles: admin,maintain,write
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,10 +26,9 @@ yarn-error.log
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/coverage
|
/coverage
|
||||||
/extensions/*
|
/extensions
|
||||||
!/extensions/.gitkeep
|
!/extensions/.gitkeep
|
||||||
!/extensions/extensions_autoload.php
|
!/extensions/extensions_autoload.php
|
||||||
!/extensions/manifest.json
|
|
||||||
/auth.json
|
/auth.json
|
||||||
/modules_statuses.json
|
/modules_statuses.json
|
||||||
/k8s
|
/k8s
|
||||||
|
|||||||
@@ -12,22 +12,6 @@ In order to keep the issues of the repository clean we decided to only use them
|
|||||||
|
|
||||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
### Vouched contributors
|
|
||||||
|
|
||||||
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
|
|
||||||
|
|
||||||
Your pull request is not affected if any of the following applies:
|
|
||||||
|
|
||||||
- You have write access to this repository.
|
|
||||||
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
|
|
||||||
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
|
|
||||||
|
|
||||||
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
|
|
||||||
|
|
||||||
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
|
|
||||||
|
|
||||||
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
|
|
||||||
|
|
||||||
### Contributor License Agreement
|
### Contributor License Agreement
|
||||||
|
|
||||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ Please open an issue or start a discussion and wait for approval before submitti
|
|||||||
|
|
||||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
||||||
|
|
||||||
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
|
|
||||||
|
|
||||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
|
||||||
|
|
||||||
enum TagMatchType: string
|
|
||||||
{
|
|
||||||
use LaravelEnumHelper;
|
|
||||||
|
|
||||||
case Contains = 'contains';
|
|
||||||
|
|
||||||
case NotContains = 'not_contains';
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,6 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy TeamMemberAdded event.
|
|
||||||
*/
|
|
||||||
class MemberAdded
|
class MemberAdded
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy AddingTeamMember event.
|
|
||||||
*/
|
|
||||||
class MemberAdding
|
class MemberAdding
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces legacy InvitingTeamMember event.
|
|
||||||
*/
|
|
||||||
class OrganizationInvitationAdding
|
class OrganizationInvitationAdding
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class UserResource extends Resource
|
|||||||
->ignore($record?->getKey()),
|
->ignore($record?->getKey()),
|
||||||
])
|
])
|
||||||
->rule([
|
->rule([
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
])
|
])
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
Forms\Components\Toggle::make('is_placeholder')
|
Forms\Components\Toggle::make('is_placeholder')
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ class ClientController extends Controller
|
|||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc');
|
||||||
->orderBy('id');
|
|
||||||
|
|
||||||
if (! $canViewAllClients) {
|
if (! $canViewAllClients) {
|
||||||
$clientsQuery->visibleByEmployee($user);
|
$clientsQuery->visibleByEmployee($user);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class InvitationController extends Controller
|
|||||||
|
|
||||||
$invitations = $organization->organizationInvitations()
|
$invitations = $organization->organizationInvitations()
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class MemberController extends Controller
|
|||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
|
||||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
@@ -125,7 +124,7 @@ class OrganizationController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, OrganizationDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:delete');
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $projectsQuery
|
$projects = $projectsQuery
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class ProjectMemberController extends Controller
|
|||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class ReportController extends Controller
|
|||||||
$filter->addBillable($properties->billable);
|
$filter->addBillable($properties->billable);
|
||||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||||
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
|
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||||
$timeEntriesQuery = $filter->get();
|
$timeEntriesQuery = $filter->get();
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class ReportController extends Controller
|
|||||||
|
|
||||||
$reports = Report::query()
|
$reports = Report::query()
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
@@ -97,7 +96,6 @@ class ReportController extends Controller
|
|||||||
$properties->setClientIds($request->input('properties.client_ids', null));
|
$properties->setClientIds($request->input('properties.client_ids', null));
|
||||||
$properties->setProjectIds($request->input('properties.project_ids', null));
|
$properties->setProjectIds($request->input('properties.project_ids', null));
|
||||||
$properties->setTagIds($request->input('properties.tag_ids', null));
|
$properties->setTagIds($request->input('properties.tag_ids', null));
|
||||||
$properties->setTagMatchType($request->getPropertyTagMatchType());
|
|
||||||
$properties->setTaskIds($request->input('properties.task_ids', null));
|
$properties->setTaskIds($request->input('properties.task_ids', null));
|
||||||
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class TagController extends Controller
|
|||||||
$tags = Tag::query()
|
$tags = Tag::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TagCollection($tags);
|
return new TagCollection($tags);
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$tasks = $query
|
$tasks = $query
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$query = TimeEntry::query()
|
$query = TimeEntry::query()
|
||||||
->where('organization_id', $organization->getKey())
|
->where('organization_id', $organization->getKey())
|
||||||
->where('member_id', $member->getKey())
|
->where('user_id', $member->user_id)
|
||||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||||
$q->where('id', '!=', $exclude->getKey());
|
$q->where('id', '!=', $exclude->getKey());
|
||||||
})
|
})
|
||||||
@@ -107,8 +107,8 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get time entries in organization
|
* Get time entries in organization
|
||||||
*
|
*
|
||||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||||
*
|
*
|
||||||
* @return TimeEntryCollection<TimeEntryResource>
|
* @return TimeEntryCollection<TimeEntryResource>
|
||||||
*
|
*
|
||||||
@@ -118,17 +118,16 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
|
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
|
|
||||||
$totalCount = $timeEntriesQuery->count();
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
@@ -159,7 +158,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($timeEntries->count() === 0) {
|
if ($timeEntries->count() === 0) {
|
||||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||||
'date' => $lastDate->toDateString(),
|
'date' => $lastDate->toDateString(),
|
||||||
'member_id' => $request->input('member_id'),
|
'user_id' => $request->input('user_id'),
|
||||||
'auth_user_id' => Auth::id(),
|
'auth_user_id' => Auth::id(),
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
@@ -195,8 +194,7 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->select($select)
|
->select($select)
|
||||||
->orderBy('time_entries.start', 'desc')
|
->orderBy('start', 'desc');
|
||||||
->orderBy('time_entries.id');
|
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
$filter->addStartFilter($request->input('start'));
|
$filter->addStartFilter($request->input('start'));
|
||||||
@@ -205,7 +203,7 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addMemberIdFilter($member);
|
$filter->addMemberIdFilter($member);
|
||||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
@@ -222,10 +220,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -242,7 +239,7 @@ class TimeEntryController extends Controller
|
|||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
'task',
|
'task',
|
||||||
'client',
|
'client',
|
||||||
@@ -265,7 +262,7 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
@@ -372,10 +369,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -386,7 +382,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -422,10 +418,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $memberFilter */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($member !== null && $member->user_id === Auth::id()) {
|
||||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:view:own');
|
$this->checkPermission($organization, 'time-entries:view:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
@@ -441,7 +436,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -564,7 +559,7 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addMemberIdFilter($member);
|
$filter->addMemberIdFilter($member);
|
||||||
$filter->addMemberIdsFilter($request->input('member_ids'));
|
$filter->addMemberIdsFilter($request->input('member_ids'));
|
||||||
$filter->addProjectIdsFilter($request->input('project_ids'));
|
$filter->addProjectIdsFilter($request->input('project_ids'));
|
||||||
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
|
$filter->addTagIdsFilter($request->input('tag_ids'));
|
||||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
@@ -584,7 +579,7 @@ class TimeEntryController extends Controller
|
|||||||
{
|
{
|
||||||
/** @var Member $member */
|
/** @var Member $member */
|
||||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||||
if ($member->getKey() === $this->member($organization)->getKey()) {
|
if ($member->user_id === Auth::id()) {
|
||||||
$this->checkPermission($organization, 'time-entries:create:own');
|
$this->checkPermission($organization, 'time-entries:create:own');
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:create:all');
|
$this->checkPermission($organization, 'time-entries:create:all');
|
||||||
@@ -631,10 +626,9 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
/** @var Member|null $member */
|
||||||
/** @var Member|null $newMember */
|
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||||
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||||
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
@@ -666,10 +660,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user()->associate($newMember->user);
|
|
||||||
}
|
|
||||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
@@ -699,7 +689,6 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
|
||||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||||
|
|
||||||
@@ -724,9 +713,6 @@ class TimeEntryController extends Controller
|
|||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Member|null $newMember */
|
|
||||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
|
||||||
|
|
||||||
$project = null;
|
$project = null;
|
||||||
$client = null;
|
$client = null;
|
||||||
$overwriteClient = false;
|
$overwriteClient = false;
|
||||||
@@ -753,7 +739,7 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -763,10 +749,6 @@ class TimeEntryController extends Controller
|
|||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
if ($newMember !== null) {
|
|
||||||
$timeEntry->member()->associate($newMember);
|
|
||||||
$timeEntry->user_id = $newMember->user_id;
|
|
||||||
}
|
|
||||||
// If project is changed, but task is not, we remove the old task from the time entry
|
// If project is changed, but task is not, we remove the old task from the time entry
|
||||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||||
$timeEntry->task()->disassociate();
|
$timeEntry->task()->disassociate();
|
||||||
@@ -807,8 +789,7 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
if ($timeEntry->member->user_id === Auth::id()) {
|
||||||
if ($timeEntry->member_id === $member->getKey()) {
|
|
||||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||||
} else {
|
} else {
|
||||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||||
@@ -865,7 +846,7 @@ class TimeEntryController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||||
$error->push($id);
|
$error->push($id);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
use App\Http\Requests\V1\User\UserDestroyRequest;
|
|
||||||
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
|
use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest;
|
||||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
@@ -194,7 +193,7 @@ class UserController extends Controller
|
|||||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||||
*/
|
*/
|
||||||
public function destroy(User $user, UserDestroyRequest $request, DeletionService $deletionService): JsonResponse
|
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||||
{
|
{
|
||||||
if ($user->getKey() !== $this->user()->getKey()) {
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
throw new AuthorizationException;
|
throw new AuthorizationException;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EnsureEmailIsVerified
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
|
public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response
|
||||||
{
|
{
|
||||||
if (! app()->isLocal() || config('app.local_email_verification')) {
|
if (! app()->isLocal()) {
|
||||||
if ($request->user() === null ||
|
if ($request->user() === null ||
|
||||||
(! $request->user()->hasVerifiedEmail())) {
|
(! $request->user()->hasVerifiedEmail())) {
|
||||||
return $request->expectsJson()
|
return $request->expectsJson()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
],
|
],
|
||||||
'role' => [
|
'role' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\Organization;
|
|
||||||
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
|
|
||||||
class OrganizationDestroyRequest extends BaseFormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'password' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, callable(Validator): void>
|
|
||||||
*/
|
|
||||||
public function after(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
function (Validator $validator): void {
|
|
||||||
if ($validator->errors()->has('password')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->user();
|
|
||||||
$password = $this->input('password');
|
|
||||||
|
|
||||||
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
|
|
||||||
$validator->errors()->add('password', __('The password is incorrect.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ use App\Enums\TimeFormat;
|
|||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Rules\CurrencyRule;
|
use App\Rules\CurrencyRule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +22,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|\Illuminate\Contracts\Validation\ValidationRule>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\Report;
|
namespace App\Http\Requests\V1\Report;
|
||||||
|
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'properties.tag_match_type' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
'properties.task_ids' => [
|
'properties.task_ids' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'array',
|
'array',
|
||||||
@@ -255,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPropertyTagMatchType(): ?TagMatchType
|
|
||||||
{
|
|
||||||
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TagMatchType::from($this->input('properties.tag_match_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -140,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'tag_match_type' => [
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -251,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTagMatchType(): ?TagMatchType
|
|
||||||
{
|
|
||||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TagMatchType::from($this->validated('tag_match_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
@@ -126,10 +125,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'tag_match_type' => [
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -213,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTagMatchType(): ?TagMatchType
|
|
||||||
{
|
|
||||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TagMatchType::from($this->validated('tag_match_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -111,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'tag_match_type' => [
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -220,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
return ExportFormat::from($this->validated('format'));
|
return ExportFormat::from($this->validated('format'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTagMatchType(): ?TagMatchType
|
|
||||||
{
|
|
||||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TagMatchType::from($this->validated('tag_match_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
@@ -104,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
})->uuid()->validate($attribute, $value, $fail);
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'tag_match_type' => [
|
|
||||||
'string',
|
|
||||||
Rule::enum(TagMatchType::class),
|
|
||||||
],
|
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -195,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTagMatchType(): ?TagMatchType
|
|
||||||
{
|
|
||||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TagMatchType::from($this->validated('tag_match_type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoundingType(): ?TimeEntryRoundingType
|
public function getRoundingType(): ?TimeEntryRoundingType
|
||||||
{
|
{
|
||||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\User;
|
|
||||||
|
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
|
|
||||||
class UserDestroyRequest extends BaseFormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'password' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, callable(Validator): void>
|
|
||||||
*/
|
|
||||||
public function after(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
function (Validator $validator): void {
|
|
||||||
if ($validator->errors()->has('password')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->user();
|
|
||||||
$password = $this->input('password');
|
|
||||||
|
|
||||||
if (! is_string($password) || $user === null || ! Hash::check($password, (string) $user->password)) {
|
|
||||||
$validator->errors()->add('password', __('The password is incorrect.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ class DetailedReportResource extends BaseResource
|
|||||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||||
/** @var string|null $tag_match_type Tag match type */
|
|
||||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
|
||||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||||
/** @var string|null $rounding_type Rounding type for time entries */
|
/** @var string|null $rounding_type Rounding type for time entries */
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the job if its models no longer exist.
|
|
||||||
*/
|
|
||||||
public bool $deleteWhenMissingModels = true;
|
|
||||||
|
|
||||||
public Project $project;
|
public Project $project;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the job if its models no longer exist.
|
|
||||||
*/
|
|
||||||
public bool $deleteWhenMissingModels = true;
|
|
||||||
|
|
||||||
public Task $task;
|
public Task $task;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service\Dto;
|
namespace App\Service\Dto;
|
||||||
|
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
@@ -57,8 +56,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public ?Collection $tagIds = null;
|
public ?Collection $tagIds = null;
|
||||||
|
|
||||||
public ?TagMatchType $tagMatchType = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, string>|null
|
* @var Collection<int, string>|null
|
||||||
*/
|
*/
|
||||||
@@ -118,7 +115,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||||
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
|
||||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||||
@@ -148,7 +144,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
'clientIds' => $value->clientIds?->toArray(),
|
'clientIds' => $value->clientIds?->toArray(),
|
||||||
'projectIds' => $value->projectIds?->toArray(),
|
'projectIds' => $value->projectIds?->toArray(),
|
||||||
'tagIds' => $value->tagIds?->toArray(),
|
'tagIds' => $value->tagIds?->toArray(),
|
||||||
'tagMatchType' => $value->tagMatchType?->value,
|
|
||||||
'taskIds' => $value->taskIds?->toArray(),
|
'taskIds' => $value->taskIds?->toArray(),
|
||||||
'group' => $value->group->value,
|
'group' => $value->group->value,
|
||||||
'subGroup' => $value->subGroup->value,
|
'subGroup' => $value->subGroup->value,
|
||||||
@@ -221,11 +216,6 @@ class ReportPropertiesDto implements Castable
|
|||||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
|
||||||
{
|
|
||||||
$this->tagMatchType = $tagMatchType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<mixed>|null $taskIds
|
* @param array<mixed>|null $taskIds
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
@@ -25,12 +24,10 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$billableRateKey = $this->getBillableRateKey($header);
|
$billableRateKey = $this->getBillableRateKey($header);
|
||||||
$tasksKey = $this->getTasksKey($header);
|
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
// Newer Clockify exports no longer contain a "Client" column.
|
if ($record['Client'] !== '') {
|
||||||
if (($record['Client'] ?? '') !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -46,13 +43,12 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'color' => $this->colorService->getRandomColor(),
|
'color' => $this->colorService->getRandomColor(),
|
||||||
'is_billable' => $record['Billability'] === 'Yes',
|
'is_billable' => $record['Billability'] === 'Yes',
|
||||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||||
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
$tasks = explode(', ', $record[$tasksKey]);
|
$tasks = explode(', ', $record['Task']);
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task,
|
'name' => $task,
|
||||||
@@ -81,36 +77,17 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
|
'Client',
|
||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
'Billability',
|
||||||
|
'Task',
|
||||||
];
|
];
|
||||||
foreach ($requiredFields as $requiredField) {
|
foreach ($requiredFields as $requiredField) {
|
||||||
if (! in_array($requiredField, $header, true)) {
|
if (! in_array($requiredField, $header, true)) {
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
|
|
||||||
if ($this->getTasksKey($header) === null) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: Tasks');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clockify names the tasks column differently depending on the export
|
|
||||||
* version: "Task" (older), "Tasks" (newer) or "Activities".
|
|
||||||
*
|
|
||||||
* @param array<string> $header
|
|
||||||
*/
|
|
||||||
private function getTasksKey(array $header): ?string
|
|
||||||
{
|
|
||||||
foreach (['Tasks', 'Task', 'Activities'] as $field) {
|
|
||||||
if (in_array($field, $header, true)) {
|
|
||||||
return $field;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$reader->setEscape('');
|
$reader->setEscape('');
|
||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$taskKey = $this->getTaskKey($header);
|
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$userId = $this->userImportHelper->getKey([
|
$userId = $this->userImportHelper->getKey([
|
||||||
@@ -72,7 +71,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
$member = $this->memberImportHelper->getModelById($memberId);
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if (($record['Client'] ?? '') !== '') {
|
if ($record['Client'] !== '') {
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
'name' => $record['Client'],
|
'name' => $record['Client'],
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($taskKey !== null && $record[$taskKey] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record[$taskKey],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
]);
|
]);
|
||||||
@@ -117,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
if (isset($record['Billable'])) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
throw new ImportException('Invalid billable value');
|
||||||
throw new ImportException('Invalid billable value');
|
|
||||||
}
|
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
|
||||||
}
|
}
|
||||||
|
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
@@ -215,11 +212,14 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
'Project',
|
'Project',
|
||||||
|
'Client',
|
||||||
'Description',
|
'Description',
|
||||||
|
'Task',
|
||||||
'User',
|
'User',
|
||||||
'Group',
|
'Group',
|
||||||
'Email',
|
'Email',
|
||||||
'Tags',
|
'Tags',
|
||||||
|
'Billable',
|
||||||
'Start Date',
|
'Start Date',
|
||||||
'Start Time',
|
'Start Time',
|
||||||
'End Date',
|
'End Date',
|
||||||
@@ -230,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
|
|
||||||
if ($this->getTaskKey($header) === null) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: Task');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clockify names the task column "Task" or "Activity" depending on the export version.
|
|
||||||
*
|
|
||||||
* @param array<string> $header
|
|
||||||
*/
|
|
||||||
private function getTaskKey(array $header): ?string
|
|
||||||
{
|
|
||||||
foreach (['Task', 'Activity'] as $field) {
|
|
||||||
if (in_array($field, $header, true)) {
|
|
||||||
return $field;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\Override]
|
#[\Override]
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
}, validate: [
|
}, validate: [
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email:rfc,strict',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($projects as $project) {
|
foreach ($projects as $project) {
|
||||||
$projectExternalId = $this->guardExternalIdentifier($project->id);
|
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($project->client_id !== null) {
|
if ($project->client_id !== null) {
|
||||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
||||||
@@ -147,16 +146,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
||||||
], (string) $project->id);
|
], (string) $project->id);
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
|
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));
|
||||||
if ($projectMembersFileContent === false) {
|
if ($projectMembersFileContent === false) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened');
|
||||||
}
|
}
|
||||||
$projectMembers = json_decode($projectMembersFileContent);
|
$projectMembers = json_decode($projectMembersFileContent);
|
||||||
if ($projectMembers === null) {
|
if ($projectMembers === null) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" is empty');
|
||||||
}
|
}
|
||||||
foreach ($projectMembers as $projectMember) {
|
foreach ($projectMembers as $projectMember) {
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
||||||
@@ -171,7 +170,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$projectIds = $this->projectImportHelper->getExternalIds();
|
$projectIds = $this->projectImportHelper->getExternalIds();
|
||||||
foreach ($projectIds as $projectIdExternal) {
|
foreach ($projectIds as $projectIdExternal) {
|
||||||
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
|
|
||||||
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -211,30 +209,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure an externally-sourced identifier can be safely used inside a
|
|
||||||
* filesystem path. The identifiers originate from the untrusted uploaded
|
|
||||||
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
|
|
||||||
* parent directory of the resolved path, so an unfiltered "../" sequence
|
|
||||||
* would escape the import sandbox and create/probe arbitrary paths on the
|
|
||||||
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
|
|
||||||
* conservative allow-list rejects traversal without affecting real data.
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function guardExternalIdentifier(mixed $id): string
|
|
||||||
{
|
|
||||||
if (! is_string($id) && ! is_int($id)) {
|
|
||||||
throw new ImportException('Invalid identifier in import data');
|
|
||||||
}
|
|
||||||
$id = (string) $id;
|
|
||||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
|
|
||||||
throw new ImportException('Invalid identifier in import data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\TagMatchType;
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -193,21 +192,15 @@ class TimeEntryFilter
|
|||||||
/**
|
/**
|
||||||
* @param array<string>|null $tagIds
|
* @param array<string>|null $tagIds
|
||||||
*/
|
*/
|
||||||
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
|
public function addTagIdsFilter(?array $tagIds): self
|
||||||
{
|
{
|
||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$tagMatchType ??= TagMatchType::Contains;
|
|
||||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
|
|
||||||
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
|
|
||||||
if (count($tagIds) === 0 && ! $includeNone) {
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
|
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
@@ -216,18 +209,7 @@ class TimeEntryFilter
|
|||||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
if ($tagMatchType === TagMatchType::NotContains) {
|
|
||||||
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
|
|
||||||
$builder->whereNot($tagCondition);
|
|
||||||
if (! $includeNone) {
|
|
||||||
$builder->orWhereNull('tags');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$this->builder->where($tagCondition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ return [
|
|||||||
|
|
||||||
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
|
||||||
|
|
||||||
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Timezone
|
| Application Timezone
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
|
|||||||
WITH_SCHEDULER=false \
|
WITH_SCHEDULER=false \
|
||||||
WITH_REVERB=false
|
WITH_REVERB=false
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||||
|
|
||||||
RUN test -z "$(find . -name .git -print -quit)"
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
|
|||||||
|
|
||||||
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
// Create projects for the first client
|
// Create projects for the first client
|
||||||
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
@@ -374,119 +374,3 @@ test.describe('Employee Clients Restrictions', () => {
|
|||||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Clients Pagination', () => {
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('test that client table paginates when there are more than 15 clients', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
|
|
||||||
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `PaginationClient ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Default sort is name asc; first 15 clients (00–14) on page 1.
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
// Client 15 should be on page 2, not visible on page 1.
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Exactly 15 data rows mounted on page 1.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(15);
|
|
||||||
|
|
||||||
// Navigation to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
// Page 2 contains the remaining 2 clients.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(2);
|
|
||||||
|
|
||||||
// Back to page 1 via Previous Page.
|
|
||||||
await page.getByRole('button', { name: 'Previous Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// First / Last page jumps.
|
|
||||||
await page.getByRole('button', { name: 'Last Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'First Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Direct page-number button navigation + selected state.
|
|
||||||
await page.getByRole('button', { name: 'Page 2' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, {
|
|
||||||
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that changing the sort resets client pagination to page 1', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `SortPagClient ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
|
|
||||||
// Sort by name descending.
|
|
||||||
const table = page.getByTestId('client_table');
|
|
||||||
const nameHeader = table.getByText('Name').first();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
|
|
||||||
await expect(page.getByText(prefix + '16')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from '../playwright/fixtures';
|
import { expect, test } from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
|
||||||
async function goToOrganizationSettings(page) {
|
async function goToOrganizationSettings(page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
@@ -471,7 +471,6 @@ test.describe('Organization Create, Delete & Switch', () => {
|
|||||||
|
|
||||||
// Open the confirmation modal, then confirm inside the dialog.
|
// Open the confirmation modal, then confirm inside the dialog.
|
||||||
await page.getByRole('button', { name: 'Delete Organization' }).click();
|
await page.getByRole('button', { name: 'Delete Organization' }).click();
|
||||||
await page.getByRole('dialog').getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(
|
page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
@@ -489,28 +488,6 @@ test.describe('Organization Create, Delete & Switch', () => {
|
|||||||
).not.toContainText(orgName);
|
).not.toContainText(orgName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete organization shows an error when the password is wrong', async ({ page }) => {
|
|
||||||
const orgName = 'DeleteOrgWrongPassword' + Math.floor(Math.random() * 100000);
|
|
||||||
await createOrganization(page, orgName);
|
|
||||||
await goToOrganizationSettings(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Delete Organization' }).click();
|
|
||||||
const dialog = page.getByRole('dialog');
|
|
||||||
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForResponse(
|
|
||||||
(response) =>
|
|
||||||
response.url().includes('/api/v1/organizations') &&
|
|
||||||
response.request().method() === 'DELETE' &&
|
|
||||||
response.status() === 422
|
|
||||||
),
|
|
||||||
dialog.getByRole('button', { name: 'Delete Organization' }).click(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(dialog.getByRole('alert')).toBeVisible();
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can switch the current organization via the organization switcher', async ({ page }) => {
|
test('can switch the current organization via the organization switcher', async ({ page }) => {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
|
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
|
||||||
|
|||||||
@@ -342,8 +342,8 @@ test('delete account shows an error when the password is wrong', async ({ page }
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(
|
page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/api/v1/users/') &&
|
response.url().includes('/user/confirm-password') &&
|
||||||
response.request().method() === 'DELETE' &&
|
response.request().method() === 'POST' &&
|
||||||
response.status() === 422
|
response.status() === 422
|
||||||
),
|
),
|
||||||
dialog.getByRole('button', { name: 'Delete Account' }).click(),
|
dialog.getByRole('button', { name: 'Delete Account' }).click(),
|
||||||
|
|||||||
@@ -117,43 +117,6 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
|
|||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
|
|
||||||
const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
|
||||||
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Open the project's Edit modal.
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
|
|
||||||
// Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
|
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
|
|
||||||
const clientSearch = page.getByPlaceholder('Search for a client...');
|
|
||||||
await expect(clientSearch).toBeFocused();
|
|
||||||
await clientSearch.fill(clientName);
|
|
||||||
await page.getByRole('option', { name: clientName }).click();
|
|
||||||
|
|
||||||
// The trigger updates to the chosen client.
|
|
||||||
await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();
|
|
||||||
|
|
||||||
// Saving persists the client assignment.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Update Project' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
async (response) =>
|
|
||||||
response.url().includes('/projects/') &&
|
|
||||||
response.request().method() === 'PUT' &&
|
|
||||||
response.status() === 200 &&
|
|
||||||
(await response.json()).data.client_id === client.id
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
@@ -1091,119 +1054,3 @@ test.describe('Employee Billable Rate Visibility', () => {
|
|||||||
await expect(projectRow).toContainText('200');
|
await expect(projectRow).toContainText('200');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Projects Pagination', () => {
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('test that project table paginates when there are more than 15 projects', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
|
|
||||||
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `PaginationProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
// Project 15 should be on page 2, not visible on page 1.
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Exactly 15 data rows should be mounted on page 1.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(15);
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
// Page 2 contains the remaining 2 projects (15, 16).
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(2);
|
|
||||||
|
|
||||||
// Return to page 1 via Previous Page.
|
|
||||||
await page.getByRole('button', { name: 'Previous Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Jump to last page then back to first page.
|
|
||||||
await page.getByRole('button', { name: 'Last Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'First Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Direct page-number button navigation.
|
|
||||||
await page.getByRole('button', { name: 'Page 2' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
// Page 2 button should be marked as selected.
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, {
|
|
||||||
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `SortPagProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
|
|
||||||
// Sort by name descending: header click toggles asc → desc.
|
|
||||||
const nameHeader = page
|
|
||||||
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
|
|
||||||
.first();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
|
|
||||||
await expect(page.getByText(prefix + '16')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
// Index 00 should now be on page 2 (last in desc order).
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -717,108 +717,3 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
|
|||||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Reporting Detailed Pagination', () => {
|
|
||||||
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// The detailed report paginates server-side with a page limit of 15.
|
|
||||||
// Create 17 time entries on a single project so we get exactly 2 pages.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `ReportPagProj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
const descriptions = Array.from(
|
|
||||||
{ length: 17 },
|
|
||||||
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
descriptions.map((description) =>
|
|
||||||
createTimeEntryViaApi(ctx, {
|
|
||||||
description,
|
|
||||||
duration: '30min',
|
|
||||||
projectId: project.id,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination nav should be rendered.
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
|
|
||||||
// Collect which descriptions are currently visible on page 1.
|
|
||||||
const visiblePage1 = new Set<string>();
|
|
||||||
for (const description of descriptions) {
|
|
||||||
if ((await page.getByText(description).count()) > 0) {
|
|
||||||
visiblePage1.add(description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The page limit is 15 → exactly 15 entries visible on page 1.
|
|
||||||
expect(visiblePage1.size).toBe(15);
|
|
||||||
|
|
||||||
// Go to page 2 and wait for the server fetch.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Next Page' }).click(),
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const visiblePage2 = new Set<string>();
|
|
||||||
for (const description of descriptions) {
|
|
||||||
if ((await page.getByText(description).count()) > 0) {
|
|
||||||
visiblePage2.add(description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
|
|
||||||
expect(visiblePage2.size).toBe(2);
|
|
||||||
for (const description of visiblePage2) {
|
|
||||||
expect(visiblePage1.has(description)).toBe(false);
|
|
||||||
}
|
|
||||||
// Across both pages, all 17 entries should have been visible.
|
|
||||||
expect(visiblePage1.size + visiblePage2.size).toBe(17);
|
|
||||||
|
|
||||||
// Page 2 button is selected.
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Previous page returns to page 1.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Previous Page' }).click(),
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
]);
|
|
||||||
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `FewEntriesProj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 5 }, (_, i) =>
|
|
||||||
createTimeEntryViaApi(ctx, {
|
|
||||||
description: `FewEntries ${i} ${seed}`,
|
|
||||||
duration: '30min',
|
|
||||||
projectId: project.id,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { expect } from '@playwright/test';
|
|
||||||
import { test } from '../playwright/fixtures';
|
|
||||||
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
|
|
||||||
import { createTimeEntryWithTagViaApi } from './utils/api';
|
|
||||||
|
|
||||||
// Each test registers a new user and creates test data via the API
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
|
|
||||||
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
|
|
||||||
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
|
||||||
]);
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
|
||||||
]);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// The entry with tagA is excluded; the entry with tagB remains
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
|
|
||||||
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
|
||||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// "Contains" tagA -> only the tagA entry is listed
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
|
|
||||||
|
|
||||||
// "Does Not Contain" tagA -> flips to the tagB entry
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
|
||||||
]);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -96,37 +96,6 @@ test('test that project multiselect search filters the option list', async ({ pa
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the project filter virtualizes a long list (renders only a window)', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create many projects so the dropdown must virtualize rather than render all of them.
|
|
||||||
const projectNames = Array.from(
|
|
||||||
{ length: 80 },
|
|
||||||
(_, i) => `VirtProj ${String(i).padStart(2, '0')}`
|
|
||||||
);
|
|
||||||
await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name })));
|
|
||||||
|
|
||||||
await goToReporting(page);
|
|
||||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
|
||||||
|
|
||||||
// Only a small window of options is mounted, far fewer than the 80+ projects that exist.
|
|
||||||
await expect(page.getByRole('option').first()).toBeVisible();
|
|
||||||
const renderedCount = await page.getByRole('option').count();
|
|
||||||
expect(renderedCount).toBeGreaterThan(0);
|
|
||||||
expect(renderedCount).toBeLessThan(60);
|
|
||||||
|
|
||||||
// Virtualization must not drop options: searching narrows the list to the one deep match.
|
|
||||||
// Wait for the filtered count to settle to 1 before asserting — checking the option while
|
|
||||||
// the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake).
|
|
||||||
await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79');
|
|
||||||
await expect(page.getByRole('option')).toHaveCount(1);
|
|
||||||
await expect(page.getByRole('option')).toContainText('VirtProj 79');
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
|
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
|
||||||
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|||||||
@@ -152,49 +152,6 @@ test('test that editing a task name works', async ({ page, ctx }) => {
|
|||||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the project can be searched and changed in the create task modal', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const sourceProject = 'Source Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
const targetProject = 'Target Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await createProjectViaApi(ctx, { name: sourceProject });
|
|
||||||
const target = await createProjectViaApi(ctx, { name: targetProject });
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await page.getByText(sourceProject).first().click();
|
|
||||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
|
|
||||||
// The project dropdown is pre-filled with the source project; open it.
|
|
||||||
await page.getByRole('dialog').getByRole('button', { name: sourceProject }).click();
|
|
||||||
|
|
||||||
// Opening the dropdown focuses the search input; searching narrows it to the target project.
|
|
||||||
const projectSearch = page.getByPlaceholder('Search for a project...');
|
|
||||||
await expect(projectSearch).toBeFocused();
|
|
||||||
await projectSearch.fill('Target Project');
|
|
||||||
await page.getByRole('option', { name: targetProject }).click();
|
|
||||||
|
|
||||||
// Selecting closes the dropdown and updates the trigger to the chosen project.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('dialog').getByRole('button', { name: targetProject })
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// The new selection is what gets used when the task is created.
|
|
||||||
const taskName = 'Switched Task ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await page.getByPlaceholder('Task Name').fill(taskName);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('dialog').getByRole('button', { name: 'Create Task' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
async (response) =>
|
|
||||||
response.url().includes('/tasks') &&
|
|
||||||
response.request().method() === 'POST' &&
|
|
||||||
response.status() === 201 &&
|
|
||||||
(await response.json()).data.project_id === target.id
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
|
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
|
||||||
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
|
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
|
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|||||||
@@ -9,14 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import {
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
createProjectViaApi,
|
|
||||||
createTaskViaApi,
|
|
||||||
createClientViaApi,
|
|
||||||
archiveProjectViaApi,
|
|
||||||
markTaskDoneViaApi,
|
|
||||||
updateOrganizationCurrencyViaWeb,
|
|
||||||
} from './utils/api';
|
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -448,236 +441,3 @@ test('test that adding a project and tag before starting timer works', async ({
|
|||||||
]);
|
]);
|
||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Project / Task selector dropdown
|
|
||||||
// Regression coverage for the virtualized + lookup-map refactor of
|
|
||||||
// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search
|
|
||||||
// change, so we wait for the dashboard prefetch to settle before opening it.
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Project Task Dropdown', () => {
|
|
||||||
test.describe.configure({ timeout: 60_000 });
|
|
||||||
|
|
||||||
test('test that a project far down a long list can be found via search and selected', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Seed enough projects that the target sits outside the initially rendered window.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `VirtProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 30 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const target = prefix + '27';
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
await page.getByTestId('client_dropdown_search').fill(target);
|
|
||||||
await page.getByRole('option').filter({ hasText: target }).click();
|
|
||||||
|
|
||||||
// The trigger now reflects the selected project.
|
|
||||||
await expect(page.getByRole('button', { name: target })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that expanding a project and selecting a task works', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `ExpandProj ${seed}`;
|
|
||||||
const taskName = `ExpandTask ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
||||||
await expect(projectOption).toBeVisible();
|
|
||||||
|
|
||||||
// Expand the project's tasks via the "N Tasks" button, then select the task.
|
|
||||||
await projectOption.getByText(/Tasks/).click();
|
|
||||||
await page.getByText(taskName, { exact: true }).click();
|
|
||||||
|
|
||||||
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `KbProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
// On open the search is focused and "No Project" is highlighted.
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// Arrow down from "No Project" to the project, then select it with Enter.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Enter');
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that search filters the dropdown by project and client name', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const clientName = `FilterClient ${seed}`;
|
|
||||||
const alphaProject = `AlphaProj ${seed}`;
|
|
||||||
const betaProject = `BetaProj ${seed}`;
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id });
|
|
||||||
await createProjectViaApi(ctx, { name: betaProject });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
const alphaOption = page.getByRole('option').filter({ hasText: alphaProject });
|
|
||||||
const betaOption = page.getByRole('option').filter({ hasText: betaProject });
|
|
||||||
|
|
||||||
// Both projects are visible before filtering.
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).toBeVisible();
|
|
||||||
|
|
||||||
// Project-name search shows only the matching project.
|
|
||||||
await search.fill('AlphaProj');
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).not.toBeVisible();
|
|
||||||
|
|
||||||
// Client-name search shows the project that belongs to that client.
|
|
||||||
await search.fill(clientName);
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectWithTask = `TaskSearchProj ${seed}`;
|
|
||||||
const taskName = `Findable Task ${seed}`;
|
|
||||||
const unrelatedProject = `Unrelated Proj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectWithTask });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
await createProjectViaApi(ctx, { name: unrelatedProject });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
await page.getByTestId('client_dropdown_search').fill(taskName);
|
|
||||||
|
|
||||||
// The project owning the task is shown (with the task), the unrelated project is not.
|
|
||||||
await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible();
|
|
||||||
await expect(page.getByText(taskName, { exact: true })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('option').filter({ hasText: unrelatedProject })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const activeProject = `ActiveProj ${seed}`;
|
|
||||||
const archivedProject = `ArchivedProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: activeProject });
|
|
||||||
const toArchive = await createProjectViaApi(ctx, { name: archivedProject });
|
|
||||||
await archiveProjectViaApi(ctx, toArchive);
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
|
|
||||||
// Wait for the list to load, then confirm the archived project is filtered out.
|
|
||||||
await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('option').filter({ hasText: archivedProject })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `DoneTaskProj ${seed}`;
|
|
||||||
const activeTask = `Active Task ${seed}`;
|
|
||||||
const doneTask = `Done Task ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: activeTask, project_id: project.id });
|
|
||||||
const taskToFinish = await createTaskViaApi(ctx, {
|
|
||||||
name: doneTask,
|
|
||||||
project_id: project.id,
|
|
||||||
});
|
|
||||||
await markTaskDoneViaApi(ctx, taskToFinish);
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
||||||
await expect(projectOption).toBeVisible();
|
|
||||||
await projectOption.getByText(/Tasks/).click();
|
|
||||||
|
|
||||||
// Only the active task shows; the done task is filtered out.
|
|
||||||
await expect(page.getByText(activeTask, { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that keyboard navigation can expand a project and select a task', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `KbTaskProj ${seed}`;
|
|
||||||
const taskName = `KbTask ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// No Project is highlighted on open: down to the project, right to expand its tasks,
|
|
||||||
// down to the task, Enter to select it.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('ArrowRight');
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Enter');
|
|
||||||
|
|
||||||
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `SpaceProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// Arrow down from "No Project" to the project, then the space shortcut selects it.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Space');
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -373,20 +373,6 @@ export async function createTaskViaApi(
|
|||||||
return body.data as { id: string; name: string; project_id: string };
|
return body.data as { id: string; name: string; project_id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
|
|
||||||
const response = await ctx.request.put(
|
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks/${task.id}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
name: task.name,
|
|
||||||
is_done: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
return (await response.json()).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
||||||
const response = await ctx.request.post(
|
const response = await ctx.request.post(
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"Billing": {
|
|
||||||
"repository": "solidtime-io/extension-billing",
|
|
||||||
"ref": "v0.0.3"
|
|
||||||
},
|
|
||||||
"Services": {
|
|
||||||
"repository": "solidtime-io/extension-services",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
},
|
|
||||||
"Invoicing": {
|
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
|
||||||
"ref": "v0.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,7 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
'clockify_time_entries' => [
|
'clockify_time_entries' => [
|
||||||
'name' => 'Clockify Time Entries',
|
'name' => 'Clockify Time Entries',
|
||||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
|
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
||||||
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
|
||||||
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
||||||
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
||||||
'4. Now select the date range that you want to export in the right top. '.
|
'4. Now select the date range that you want to export in the right top. '.
|
||||||
@@ -62,8 +61,7 @@ return [
|
|||||||
],
|
],
|
||||||
'harvest_time_entries' => [
|
'harvest_time_entries' => [
|
||||||
'name' => 'Harvest Time Entries',
|
'name' => 'Harvest Time Entries',
|
||||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
|
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
||||||
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
|
||||||
'<br>3. Now click on "Export all time" '.
|
'<br>3. Now click on "Export all time" '.
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
||||||
],
|
],
|
||||||
|
|||||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -20,7 +20,6 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -5465,6 +5464,17 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-vue-next": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
|
||||||
|
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -8399,7 +8409,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.5.4"
|
"vite-plugin-dts": "^4.0.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
@@ -8414,17 +8424,15 @@
|
|||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.0.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@internationalized/date": "^3.0.0",
|
"@internationalized/date": "^3.0.0",
|
||||||
"@lucide/vue": ">=1.0.0",
|
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
@@ -8433,6 +8441,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
"lucide-vue-next": ">=0.453.0",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"radix-vue": "^1.9.0",
|
"radix-vue": "^1.9.0",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.2.0",
|
||||||
|
|||||||
@@ -64,7 +64,6 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { type Component, computed, ref, watch } from 'vue';
|
import { type Component, computed, ref } from 'vue';
|
||||||
import { type Client } from '@/packages/api/src';
|
import { type Client } from '@/packages/api/src';
|
||||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import {
|
import {
|
||||||
@@ -44,14 +43,11 @@ const projectCountMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Name is always the secondary sort so rows with equal values render
|
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
id: props.sortColumn,
|
||||||
desc: props.sortDirection === 'desc',
|
desc: props.sortDirection === 'desc',
|
||||||
},
|
},
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const columns = computed(() => [
|
const columns = computed(() => [
|
||||||
@@ -104,19 +100,6 @@ const table = useVueTable({
|
|||||||
const sortedClients = computed(() => {
|
const sortedClients = computed(() => {
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
|
||||||
const PAGE_SIZE = 15;
|
|
||||||
const currentPage = ref(1);
|
|
||||||
|
|
||||||
watch([() => props.sortColumn, () => props.sortDirection, () => props.clients], () => {
|
|
||||||
currentPage.value = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginatedClients = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * PAGE_SIZE;
|
|
||||||
return sortedClients.value.slice(start, start + PAGE_SIZE);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -143,14 +126,10 @@ const paginatedClients = computed(() => {
|
|||||||
>Create your First Client
|
>Create your First Client
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="client in paginatedClients" :key="client.id">
|
<template v-for="client in sortedClients" :key="client.id">
|
||||||
<ClientTableRow :client="client"></ClientTableRow>
|
<ClientTableRow :client="client"></ClientTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
v-model:page="currentPage"
|
|
||||||
:total="sortedClients.length"
|
|
||||||
:items-per-page="PAGE_SIZE"></Pagination>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -89,17 +89,11 @@ function selectMember(member: Member) {
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
v-model:search-term="searchValue"
|
v-model:search-term="searchValue"
|
||||||
:open="true"
|
v-model:open="open"
|
||||||
class="relative"
|
class="relative"
|
||||||
:filter-function="(val: string[]) => val"
|
:filter-function="(val: string[]) => val">
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationFirst,
|
|
||||||
PaginationLast,
|
|
||||||
PaginationList,
|
|
||||||
PaginationListItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrev,
|
|
||||||
PaginationRoot,
|
|
||||||
} from 'radix-vue';
|
|
||||||
import {
|
|
||||||
ChevronDoubleLeftIcon,
|
|
||||||
ChevronDoubleRightIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
EllipsisHorizontalIcon,
|
|
||||||
} from '@heroicons/vue/20/solid';
|
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { computed, watch } from 'vue';
|
|
||||||
|
|
||||||
const page = defineModel<number>('page', { default: 1 });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
total: number;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
siblingCount?: number;
|
|
||||||
showEdges?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
itemsPerPage: 15,
|
|
||||||
siblingCount: 1,
|
|
||||||
showEdges: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.itemsPerPage)));
|
|
||||||
|
|
||||||
watch(page, (value) => {
|
|
||||||
if (value > pageCount.value) {
|
|
||||||
page.value = pageCount.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(pageCount, (value) => {
|
|
||||||
if (page.value > value) {
|
|
||||||
page.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The shared buttonVariants ghost/outline hover is `bg-white/5`, which is invisible in light
|
|
||||||
// mode. Override it with a theme-aware hover that shows in both light and dark mode.
|
|
||||||
const hoverClass = 'hover:bg-black/5 dark:hover:bg-white/5';
|
|
||||||
const navButtonClass = cn(buttonVariants({ variant: 'ghost', size: 'icon' }), hoverClass);
|
|
||||||
|
|
||||||
function pageButtonClass(isActive: boolean): string {
|
|
||||||
return cn(
|
|
||||||
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size: 'icon' }),
|
|
||||||
hoverClass
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<PaginationRoot
|
|
||||||
v-if="pageCount > 1"
|
|
||||||
v-model:page="page"
|
|
||||||
:total="props.total"
|
|
||||||
:items-per-page="props.itemsPerPage"
|
|
||||||
:sibling-count="props.siblingCount"
|
|
||||||
:show-edges="props.showEdges"
|
|
||||||
class="mx-auto flex w-full justify-center py-8">
|
|
||||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
|
||||||
<PaginationFirst :class="navButtonClass">
|
|
||||||
<ChevronDoubleLeftIcon class="size-4" />
|
|
||||||
</PaginationFirst>
|
|
||||||
<PaginationPrev :class="navButtonClass">
|
|
||||||
<ChevronLeftIcon class="size-4" />
|
|
||||||
</PaginationPrev>
|
|
||||||
<template v-for="(item, index) in items" :key="index">
|
|
||||||
<PaginationListItem
|
|
||||||
v-if="item.type === 'page'"
|
|
||||||
:value="item.value"
|
|
||||||
:class="pageButtonClass(item.value === page)">
|
|
||||||
{{ item.value }}
|
|
||||||
</PaginationListItem>
|
|
||||||
<PaginationEllipsis
|
|
||||||
v-else
|
|
||||||
:index="index"
|
|
||||||
class="flex size-9 items-center justify-center text-text-tertiary">
|
|
||||||
<EllipsisHorizontalIcon class="size-4" />
|
|
||||||
</PaginationEllipsis>
|
|
||||||
</template>
|
|
||||||
<PaginationNext :class="navButtonClass">
|
|
||||||
<ChevronRightIcon class="size-4" />
|
|
||||||
</PaginationNext>
|
|
||||||
<PaginationLast :class="navButtonClass">
|
|
||||||
<ChevronDoubleRightIcon class="size-4" />
|
|
||||||
</PaginationLast>
|
|
||||||
</PaginationList>
|
|
||||||
</PaginationRoot>
|
|
||||||
</template>
|
|
||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
|
||||||
import { Check, Plus } from '@lucide/vue';
|
import { Check, Plus } from '@lucide/vue';
|
||||||
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
@@ -37,16 +37,7 @@ const emit = defineEmits(['update:modelValue', 'changed']);
|
|||||||
|
|
||||||
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
||||||
|
|
||||||
// Pinned on open so rows don't re-sort while interacting; the project list itself stays reactive.
|
const sortedProjects = ref<Project[]>([]);
|
||||||
const pinnedProjectId = ref<string | null>(null);
|
|
||||||
|
|
||||||
const sortedProjects = computed(() => {
|
|
||||||
return [...projects.value].sort((a, b) => {
|
|
||||||
const aPinned = pinnedProjectId.value === a.id ? 0 : 1;
|
|
||||||
const bPinned = pinnedProjectId.value === b.id ? 0 : 1;
|
|
||||||
return aPinned - bPinned;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const shownProjects = computed(() => {
|
const shownProjects = computed(() => {
|
||||||
return sortedProjects.value.filter((project) => {
|
return sortedProjects.value.filter((project) => {
|
||||||
@@ -74,7 +65,9 @@ watch(open, (isOpen) => {
|
|||||||
searchInput.value?.$el?.focus();
|
searchInput.value?.$el?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
pinnedProjectId.value = model.value;
|
sortedProjects.value = [...projects.value].sort((iteratingProject) => {
|
||||||
|
return model.value === iteratingProject.id ? -1 : 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,51 +103,40 @@ function updateValue(project: Project) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||||
<div>
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
:model-value="currentProject"
|
:model-value="currentProject"
|
||||||
class="relative"
|
class="relative"
|
||||||
:ignore-filter="true"
|
@update:model-value="updateValue">
|
||||||
@update:model-value="updateValue"
|
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="searchValue"
|
|
||||||
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a project..." />
|
placeholder="Search for a project..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport
|
<ComboboxViewport
|
||||||
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option: project }"
|
v-for="project in shownProjects"
|
||||||
:options="shownProjects"
|
:key="project.id"
|
||||||
:estimate-size="32"
|
:value="project"
|
||||||
:text-content="(p: Project) => p.name">
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
<ComboboxItem
|
:data-project-id="project.id">
|
||||||
:value="project"
|
<span class="flex items-center gap-2">
|
||||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
|
||||||
:data-project-id="project.id">
|
|
||||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<span
|
|
||||||
:style="{ backgroundColor: project.color }"
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"></span>
|
|
||||||
<span class="truncate">{{ project.name }}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="isProjectSelected(project)"
|
:style="{ backgroundColor: project.color }"
|
||||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
class="w-3 h-3 rounded-full shrink-0"></span>
|
||||||
<Check class="h-4 w-4" />
|
<span>{{ project.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
</ComboboxItem>
|
<span
|
||||||
</ComboboxVirtualizer>
|
v-if="isProjectSelected(project)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</ComboboxItem>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
<div
|
<div
|
||||||
v-if="canCreateProjects()"
|
v-if="canCreateProjects()"
|
||||||
@@ -168,7 +150,7 @@ function updateValue(project: Project) {
|
|||||||
</div>
|
</div>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
</div>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
| 'name'
|
| 'name'
|
||||||
@@ -57,15 +56,12 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert sort props to TanStack Table format.
|
// Convert sort props to TanStack Table format
|
||||||
// Name is always the secondary sort so rows with equal values render
|
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
id: props.sortColumn,
|
||||||
desc: props.sortDirection === 'desc',
|
desc: props.sortDirection === 'desc',
|
||||||
},
|
},
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
@@ -147,19 +143,6 @@ const sortedProjects = computed(() => {
|
|||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
|
||||||
const PAGE_SIZE = 15;
|
|
||||||
const currentPage = ref(1);
|
|
||||||
|
|
||||||
watch([() => props.sortColumn, () => props.sortDirection, () => props.projects], () => {
|
|
||||||
currentPage.value = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginatedProjects = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * PAGE_SIZE;
|
|
||||||
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -216,7 +199,7 @@ const gridTemplate = computed(() => {
|
|||||||
>Create your First Project
|
>Create your First Project
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="project in paginatedProjects" :key="project.id">
|
<template v-for="project in sortedProjects" :key="project.id">
|
||||||
<ProjectTableRow
|
<ProjectTableRow
|
||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:project="project"></ProjectTableRow>
|
:project="project"></ProjectTableRow>
|
||||||
@@ -224,8 +207,4 @@ const gridTemplate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
v-model:page="currentPage"
|
|
||||||
:total="sortedProjects.length"
|
|
||||||
:items-per-page="PAGE_SIZE"></Pagination>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Check } from '@lucide/vue';
|
|
||||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
@@ -16,7 +14,6 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
|||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
import type { TagMatchType } from '@/types/reporting';
|
|
||||||
|
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
|
|
||||||
@@ -25,7 +22,6 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
|
|||||||
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
|
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
|
||||||
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
|
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
|
||||||
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
||||||
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
|
|
||||||
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
||||||
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
||||||
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
||||||
@@ -39,16 +35,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { tags } = useTagsQuery();
|
const { tags } = useTagsQuery();
|
||||||
|
|
||||||
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
|
|
||||||
{ value: 'contains', label: 'Contains' },
|
|
||||||
{ value: 'not_contains', label: 'Does Not Contain' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function selectTagMatchType(value: AcceptableValue) {
|
|
||||||
tagMatchType.value = value as TagMatchType;
|
|
||||||
emit('submit');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTag(name: string) {
|
async function createTag(name: string) {
|
||||||
return await useTagsStore().createTag(name);
|
return await useTagsStore().createTag(name);
|
||||||
}
|
}
|
||||||
@@ -107,34 +93,6 @@ async function createTag(name: string) {
|
|||||||
title="Tags"
|
title="Tags"
|
||||||
:icon="TagIcon" />
|
:icon="TagIcon" />
|
||||||
</template>
|
</template>
|
||||||
<template #content-before-list>
|
|
||||||
<div class="mt-2 border-b border-card-background-separator pb-2">
|
|
||||||
<div
|
|
||||||
id="tag-match-type-label"
|
|
||||||
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
|
|
||||||
Match
|
|
||||||
</div>
|
|
||||||
<RadioGroupRoot
|
|
||||||
:model-value="tagMatchType"
|
|
||||||
aria-labelledby="tag-match-type-label"
|
|
||||||
class="space-y-1"
|
|
||||||
@update:model-value="selectTagMatchType">
|
|
||||||
<RadioGroupItem
|
|
||||||
v-for="option in tagMatchOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
|
|
||||||
{{ option.label }}
|
|
||||||
<span
|
|
||||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<RadioGroupIndicator>
|
|
||||||
<Check class="h-4 w-4" />
|
|
||||||
</RadioGroupIndicator>
|
|
||||||
</span>
|
|
||||||
</RadioGroupItem>
|
|
||||||
</RadioGroupRoot>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</TagDropdown>
|
</TagDropdown>
|
||||||
|
|
||||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
<Select v-model="billable" @update:model-value="emit('submit')">
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import type { ExportFormat } from '@/types/reporting';
|
|||||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
|
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
|
||||||
import type { TagMatchType } from '@/types/reporting';
|
|
||||||
|
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ const selectedProjects = ref<string[]>([]);
|
|||||||
const selectedMembers = ref<string[]>([]);
|
const selectedMembers = ref<string[]>([]);
|
||||||
const selectedTasks = ref<string[]>([]);
|
const selectedTasks = ref<string[]>([]);
|
||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
const tagMatchType = ref<TagMatchType>('contains');
|
|
||||||
|
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
const billable = ref<'true' | 'false' | null>(null);
|
||||||
const roundingEnabled = ref<boolean>(false);
|
const roundingEnabled = ref<boolean>(false);
|
||||||
@@ -124,7 +122,6 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
|||||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
|
||||||
billable: billable.value !== null ? billable.value : undefined,
|
billable: billable.value !== null ? billable.value : undefined,
|
||||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||||
@@ -369,7 +366,6 @@ const tableData = computed(() => {
|
|||||||
v-model:selected-tasks="selectedTasks"
|
v-model:selected-tasks="selectedTasks"
|
||||||
v-model:selected-clients="selectedClients"
|
v-model:selected-clients="selectedClients"
|
||||||
v-model:selected-tags="selectedTags"
|
v-model:selected-tags="selectedTags"
|
||||||
v-model:tag-match-type="tagMatchType"
|
|
||||||
v-model:billable="billable"
|
v-model:billable="billable"
|
||||||
v-model:rounding-enabled="roundingEnabled"
|
v-model:rounding-enabled="roundingEnabled"
|
||||||
v-model:rounding-type="roundingType"
|
v-model:rounding-type="roundingType"
|
||||||
|
|||||||
@@ -62,35 +62,4 @@ describe('TimesheetCell', () => {
|
|||||||
expect(wrapper.emitted('update')).toBeUndefined();
|
expect(wrapper.emitted('update')).toBeUndefined();
|
||||||
expect((input.element as HTMLInputElement).value).toBe(previousValue);
|
expect((input.element as HTMLInputElement).value).toBe(previousValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a pending 0 (delete in flight) over the cell total', () => {
|
|
||||||
const wrapper = mount(TimesheetCell, {
|
|
||||||
props: {
|
|
||||||
cell: buildCell(2 * 3600),
|
|
||||||
dayIndex: 0,
|
|
||||||
date: '2026-04-13',
|
|
||||||
isToday: false,
|
|
||||||
hasRunningEntry: false,
|
|
||||||
pendingSeconds: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// `??` (not `||`): a pending 0 must win over the 2h cell total.
|
|
||||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables editing while the cell is saving', () => {
|
|
||||||
const wrapper = mount(TimesheetCell, {
|
|
||||||
props: {
|
|
||||||
cell: buildCell(2 * 3600),
|
|
||||||
dayIndex: 0,
|
|
||||||
date: '2026-04-13',
|
|
||||||
isToday: false,
|
|
||||||
hasRunningEntry: false,
|
|
||||||
saveStatus: 'saving',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { CheckIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -10,40 +7,18 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/packages/ui/src/tooltip';
|
} from '@/packages/ui/src/tooltip';
|
||||||
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
|
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
|
||||||
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
cell?: TimesheetCell;
|
cell?: TimesheetCell;
|
||||||
dayIndex: number;
|
dayIndex: number;
|
||||||
date: string;
|
date: string;
|
||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
hasRunningEntry: boolean;
|
hasRunningEntry: boolean;
|
||||||
saveStatus?: CellSaveStatus;
|
|
||||||
pendingSeconds?: number;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [newSeconds: number];
|
update: [newSeconds: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
|
|
||||||
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
|
|
||||||
const isSaving = computed(() => props.saveStatus === 'saving');
|
|
||||||
|
|
||||||
// Swap the border color (don't layer) to avoid same-specificity fights.
|
|
||||||
const inputClass = computed(() => {
|
|
||||||
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
|
|
||||||
return [
|
|
||||||
'w-[80px] mx-auto text-center font-medium',
|
|
||||||
'bg-transparent text-text-primary placeholder:text-text-quaternary',
|
|
||||||
'rounded-lg border shadow-none',
|
|
||||||
border,
|
|
||||||
'hover:bg-card-background',
|
|
||||||
'focus-visible:bg-tertiary focus-visible:border-transparent',
|
|
||||||
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
|
||||||
'disabled:cursor-wait disabled:opacity-70',
|
|
||||||
].join(' ');
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -71,26 +46,18 @@ const inputClass = computed(() => {
|
|||||||
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<template v-else>
|
<DurationSecondsInput
|
||||||
<span class="relative inline-flex items-center">
|
v-else
|
||||||
<DurationSecondsInput
|
:model-value="cell?.totalSeconds ?? 0"
|
||||||
:model-value="displaySeconds"
|
default-unit="hours"
|
||||||
default-unit="hours"
|
placeholder="-"
|
||||||
placeholder="-"
|
size="sm"
|
||||||
size="sm"
|
input-class="w-[80px] mx-auto text-center font-medium
|
||||||
:disabled="isSaving"
|
bg-transparent text-text-primary placeholder:text-text-quaternary
|
||||||
:input-class="inputClass"
|
rounded-lg border border-input-border shadow-none
|
||||||
@commit="(seconds) => emit('update', seconds ?? 0)" />
|
hover:bg-card-background
|
||||||
<span
|
focus-visible:bg-tertiary focus-visible:border-transparent
|
||||||
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
|
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||||
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
|
@commit="(seconds) => emit('update', seconds ?? 0)" />
|
||||||
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
|
|
||||||
<LoadingSpinner
|
|
||||||
v-if="saveStatus === 'saving'"
|
|
||||||
class="h-3 w-3 m-0 text-text-tertiary" />
|
|
||||||
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
|
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
const dayjs = getDayJsInstance();
|
const dayjs = getDayJsInstance();
|
||||||
@@ -37,8 +36,6 @@ defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
formatDuration: (seconds: number) => string;
|
formatDuration: (seconds: number) => string;
|
||||||
cellStatuses: Record<string, CellSaveStatus>;
|
|
||||||
cellPendingSeconds: Record<string, number>;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -63,7 +60,7 @@ const emit = defineEmits<{
|
|||||||
class="grid min-w-full w-max border-y border-default-background-separator"
|
class="grid min-w-full w-max border-y border-default-background-separator"
|
||||||
style="
|
style="
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
|
minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
|
||||||
40px;
|
40px;
|
||||||
">
|
">
|
||||||
<!-- Header row -->
|
<!-- Header row -->
|
||||||
@@ -103,8 +100,6 @@ const emit = defineEmits<{
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
:format-duration="formatDuration"
|
:format-duration="formatDuration"
|
||||||
:cell-statuses="cellStatuses"
|
|
||||||
:cell-pending-seconds="cellPendingSeconds"
|
|
||||||
@remove-row="$emit('remove-row', $event)"
|
@remove-row="$emit('remove-row', $event)"
|
||||||
@cell-update="
|
@cell-update="
|
||||||
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
|
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import type {
|
|||||||
Organization,
|
Organization,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
import {
|
|
||||||
makeCellStatusKey,
|
|
||||||
type CellSaveStatus,
|
|
||||||
} from '@/utils/timesheet/useTimesheetCellMutations';
|
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
@@ -38,8 +34,6 @@ const props = defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
formatDuration: (seconds: number) => string;
|
formatDuration: (seconds: number) => string;
|
||||||
cellStatuses: Record<string, CellSaveStatus>;
|
|
||||||
cellPendingSeconds: Record<string, number>;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -115,8 +109,6 @@ function hasRunningEntry(dayIndex: number): boolean {
|
|||||||
:date="day"
|
:date="day"
|
||||||
:is-today="day === todayDate"
|
:is-today="day === todayDate"
|
||||||
:has-running-entry="hasRunningEntry(dayIndex)"
|
:has-running-entry="hasRunningEntry(dayIndex)"
|
||||||
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
|
|
||||||
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
|
|
||||||
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
|
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
|
||||||
|
|
||||||
<!-- Row total -->
|
<!-- Row total -->
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ const page = usePage<{
|
|||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
import ActionSection from '@/Components/ActionSection.vue';
|
import ActionSection from '@/Components/ActionSection.vue';
|
||||||
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
import { Field, FieldError } from '@/packages/ui/src/field';
|
import { Field, FieldError } from '@/packages/ui/src/field';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
import { useDeleteUserMutation } from '@/utils/useUserQuery';
|
import { useDeleteUserMutation, useUserQuery } from '@/utils/useUserQuery';
|
||||||
import { getCurrentUserId } from '@/utils/useUser';
|
|
||||||
|
|
||||||
|
const { user } = useUserQuery();
|
||||||
const deleteUserMutation = useDeleteUserMutation();
|
const deleteUserMutation = useDeleteUserMutation();
|
||||||
|
|
||||||
const confirmingUserDeletion = ref(false);
|
const confirmingUserDeletion = ref(false);
|
||||||
@@ -23,26 +24,26 @@ function confirmUserDeletion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser() {
|
async function deleteUser() {
|
||||||
if (processing.value) return;
|
if (!user.value || processing.value) return;
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
passwordError.value = '';
|
passwordError.value = '';
|
||||||
try {
|
try {
|
||||||
await deleteUserMutation.mutateAsync({
|
await axios.post(route('password.confirm'), { password: password.value });
|
||||||
userId: getCurrentUserId(),
|
|
||||||
body: { password: password.value },
|
|
||||||
});
|
|
||||||
window.location.href = '/';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const response = error.response as
|
|
||||||
| { status?: number; data?: { errors?: { password?: string[] } } }
|
|
||||||
| undefined;
|
|
||||||
if (response?.status === 422) {
|
|
||||||
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 422) {
|
||||||
|
passwordError.value = error.response.data?.errors?.password?.[0] ?? 'Invalid password.';
|
||||||
|
} else {
|
||||||
|
passwordError.value = 'Could not confirm password. Please try again.';
|
||||||
|
}
|
||||||
passwordInput.value?.focus();
|
passwordInput.value?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteUserMutation.mutateAsync(user.value.id);
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch {
|
||||||
|
processing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronDoubleLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronDoubleRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
EllipsisVerticalIcon,
|
EllipsisVerticalIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -40,6 +43,16 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||||
|
import {
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
|
PaginationList,
|
||||||
|
PaginationListItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrev,
|
||||||
|
PaginationRoot,
|
||||||
|
} from 'radix-vue';
|
||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
@@ -54,7 +67,6 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
|
|||||||
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
|
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
|
||||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
import type { TagMatchType } from '@/types/reporting';
|
|
||||||
|
|
||||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||||
@@ -72,7 +84,6 @@ const selectedProjects = ref<string[]>([]);
|
|||||||
const selectedMembers = ref<string[]>([]);
|
const selectedMembers = ref<string[]>([]);
|
||||||
const selectedTasks = ref<string[]>([]);
|
const selectedTasks = ref<string[]>([]);
|
||||||
const selectedClients = ref<string[]>([]);
|
const selectedClients = ref<string[]>([]);
|
||||||
const tagMatchType = ref<TagMatchType>('contains');
|
|
||||||
const billable = ref<'true' | 'false' | null>(null);
|
const billable = ref<'true' | 'false' | null>(null);
|
||||||
const roundingEnabled = ref<boolean>(false);
|
const roundingEnabled = ref<boolean>(false);
|
||||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||||
@@ -104,7 +115,6 @@ function getFilterAttributes() {
|
|||||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
|
||||||
billable: billable.value !== null ? billable.value : undefined,
|
billable: billable.value !== null ? billable.value : undefined,
|
||||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||||
@@ -327,7 +337,6 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
v-model:selected-tasks="selectedTasks"
|
v-model:selected-tasks="selectedTasks"
|
||||||
v-model:selected-clients="selectedClients"
|
v-model:selected-clients="selectedClients"
|
||||||
v-model:selected-tags="selectedTags"
|
v-model:selected-tags="selectedTags"
|
||||||
v-model:tag-match-type="tagMatchType"
|
|
||||||
v-model:billable="billable"
|
v-model:billable="billable"
|
||||||
v-model:rounding-enabled="roundingEnabled"
|
v-model:rounding-enabled="roundingEnabled"
|
||||||
v-model:rounding-type="roundingType"
|
v-model:rounding-type="roundingType"
|
||||||
@@ -401,6 +410,62 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination v-model:page="currentPage" :total="totalPages" :items-per-page="pageLimit" />
|
<PaginationRoot
|
||||||
|
v-model:page="currentPage"
|
||||||
|
:total="totalPages"
|
||||||
|
:items-per-page="pageLimit"
|
||||||
|
class="flex justify-center items-center py-8"
|
||||||
|
:sibling-count="1"
|
||||||
|
show-edges>
|
||||||
|
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
|
||||||
|
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
|
||||||
|
<PaginationFirst class="navigation-item">
|
||||||
|
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
|
||||||
|
</PaginationFirst>
|
||||||
|
<PaginationPrev class="mr-4 navigation-item">
|
||||||
|
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
|
||||||
|
</ChevronLeftIcon>
|
||||||
|
</PaginationPrev>
|
||||||
|
</div>
|
||||||
|
<template v-for="(page, index) in items">
|
||||||
|
<PaginationListItem
|
||||||
|
v-if="page.type === 'page'"
|
||||||
|
:key="index"
|
||||||
|
class="pagination-item"
|
||||||
|
:value="page.value">
|
||||||
|
{{ page.value }}
|
||||||
|
</PaginationListItem>
|
||||||
|
<PaginationEllipsis
|
||||||
|
v-else
|
||||||
|
:key="page.type"
|
||||||
|
:index="index"
|
||||||
|
class="PaginationEllipsis">
|
||||||
|
<div class="px-2">…</div>
|
||||||
|
</PaginationEllipsis>
|
||||||
|
</template>
|
||||||
|
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
|
||||||
|
<PaginationNext class="navigation-item">
|
||||||
|
<ChevronRightIcon
|
||||||
|
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
|
||||||
|
</PaginationNext>
|
||||||
|
<PaginationLast class="navigation-item">
|
||||||
|
<ChevronDoubleRightIcon
|
||||||
|
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
|
||||||
|
</PaginationLast>
|
||||||
|
</div>
|
||||||
|
</PaginationList>
|
||||||
|
</PaginationRoot>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
<style lang="postcss">
|
||||||
|
.navigation-item {
|
||||||
|
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-item {
|
||||||
|
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
|
||||||
|
}
|
||||||
|
.pagination-item[data-selected] {
|
||||||
|
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import ActionSection from '@/Components/ActionSection.vue';
|
import ActionSection from '@/Components/ActionSection.vue';
|
||||||
|
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
|
||||||
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import { Field, FieldError } from '@/packages/ui/src/field';
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,46 +12,26 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const confirmingTeamDeletion = ref(false);
|
const confirmingTeamDeletion = ref(false);
|
||||||
const passwordInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const password = ref('');
|
|
||||||
const passwordError = ref('');
|
|
||||||
const processing = ref(false);
|
const processing = ref(false);
|
||||||
const organizationStore = useOrganizationStore();
|
const organizationStore = useOrganizationStore();
|
||||||
|
|
||||||
const confirmTeamDeletion = () => {
|
const confirmTeamDeletion = () => {
|
||||||
confirmingTeamDeletion.value = true;
|
confirmingTeamDeletion.value = true;
|
||||||
setTimeout(() => passwordInput.value?.focus(), 250);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTeam = async () => {
|
const deleteTeam = async () => {
|
||||||
if (processing.value) return;
|
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
passwordError.value = '';
|
|
||||||
try {
|
try {
|
||||||
await organizationStore.deleteOrganization(props.team.id, { password: password.value });
|
await organizationStore.deleteOrganization(props.team.id);
|
||||||
// The backend reassigns the user's current organization after deletion,
|
// The backend reassigns the user's current organization after deletion,
|
||||||
// so flush the prefetch cache and reload into the dashboard.
|
// so flush the prefetch cache and reload into the dashboard.
|
||||||
router.flushAll();
|
router.flushAll();
|
||||||
router.visit(route('dashboard'));
|
router.visit(route('dashboard'));
|
||||||
} catch (error) {
|
} catch {
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
// Request errors are surfaced as notifications by the store.
|
||||||
const response = error.response as
|
|
||||||
| { status?: number; data?: { errors?: { password?: string[] } } }
|
|
||||||
| undefined;
|
|
||||||
if (response?.status === 422) {
|
|
||||||
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
passwordInput.value?.focus();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
confirmingTeamDeletion.value = false;
|
|
||||||
password.value = '';
|
|
||||||
passwordError.value = '';
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,30 +52,20 @@ const closeModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Organization Confirmation Modal -->
|
<!-- Delete Organization Confirmation Modal -->
|
||||||
<DialogModal :show="confirmingTeamDeletion" @close="closeModal">
|
<ConfirmationModal
|
||||||
|
:show="confirmingTeamDeletion"
|
||||||
|
@close="confirmingTeamDeletion = false">
|
||||||
<template #title> Delete Organization </template>
|
<template #title> Delete Organization </template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
Are you sure you want to delete this organization? Once a organization is
|
Are you sure you want to delete this organization? Once a organization is
|
||||||
deleted, all of its resources and data will be permanently deleted. Please enter
|
deleted, all of its resources and data will be permanently deleted.
|
||||||
your password to confirm you would like to permanently delete this organization.
|
|
||||||
|
|
||||||
<Field class="mt-4">
|
|
||||||
<TextInput
|
|
||||||
ref="passwordInput"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
class="block w-3/4"
|
|
||||||
placeholder="Password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
@keyup.enter="deleteTeam" />
|
|
||||||
|
|
||||||
<FieldError v-if="passwordError">{{ passwordError }}</FieldError>
|
|
||||||
</Field>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
|
<SecondaryButton @click="confirmingTeamDeletion = false">
|
||||||
|
Cancel
|
||||||
|
</SecondaryButton>
|
||||||
|
|
||||||
<DangerButton
|
<DangerButton
|
||||||
class="ms-3"
|
class="ms-3"
|
||||||
@@ -107,7 +75,7 @@ const closeModal = () => {
|
|||||||
Delete Organization
|
Delete Organization
|
||||||
</DangerButton>
|
</DangerButton>
|
||||||
</template>
|
</template>
|
||||||
</DialogModal>
|
</ConfirmationModal>
|
||||||
</template>
|
</template>
|
||||||
</ActionSection>
|
</ActionSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -43,17 +43,17 @@ onMounted(async () => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
<SectionBorder />
|
||||||
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
|
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
|
||||||
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
|
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
|
||||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||||
|
<SectionBorder />
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam">
|
<template v-if="permissions.canDeleteTeam">
|
||||||
<SectionBorder />
|
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -90,12 +90,7 @@ const weekRangeDisplay = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
|
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
|
||||||
weekDays,
|
|
||||||
timeEntries,
|
|
||||||
rows,
|
|
||||||
removeSlot
|
|
||||||
);
|
|
||||||
|
|
||||||
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
|
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
|
||||||
mutations,
|
mutations,
|
||||||
@@ -172,8 +167,6 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
:format-duration="formatDuration"
|
:format-duration="formatDuration"
|
||||||
:cell-statuses="cellStatus"
|
|
||||||
:cell-pending-seconds="cellPendingSeconds"
|
|
||||||
@remove-row="handleRemoveRow"
|
@remove-row="handleRemoveRow"
|
||||||
@cell-update="handleCellUpdate"
|
@cell-update="handleCellUpdate"
|
||||||
@project-task-change="
|
@project-task-change="
|
||||||
|
|||||||
@@ -114,8 +114,6 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
|
|||||||
|
|
||||||
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
|
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
|
||||||
|
|
||||||
export type DetailedInvoice = DetailedInvoiceResponse['data'];
|
|
||||||
|
|
||||||
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
|
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
|
||||||
|
|
||||||
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
|
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;
|
||||||
@@ -126,8 +124,6 @@ export type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>
|
|||||||
|
|
||||||
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
|
export type User = ZodiosResponseByAlias<SolidTimeApi, 'getMe'>['data'];
|
||||||
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
|
export type UpdateUserBody = ZodiosBodyByAlias<SolidTimeApi, 'updateUser'>;
|
||||||
export type DeleteUserBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteUser'>;
|
|
||||||
export type DeleteOrganizationBody = ZodiosBodyByAlias<SolidTimeApi, 'deleteOrganization'>;
|
|
||||||
|
|
||||||
const api = createApiClient('/api', { validate: 'none' });
|
const api = createApiClient('/api', { validate: 'none' });
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passth
|
|||||||
const ClientUpdateRequest = z
|
const ClientUpdateRequest = z
|
||||||
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
.object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const DestroyWithPasswordRequest = z.object({ password: z.string() }).passthrough();
|
|
||||||
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
const ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();
|
||||||
const InvitationResource = z
|
const InvitationResource = z
|
||||||
.object({ id: z.string(), email: z.string(), role: z.string() })
|
.object({ id: z.string(), email: z.string(), role: z.string() })
|
||||||
@@ -448,7 +447,6 @@ const ReportStoreRequest = z
|
|||||||
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||||
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||||
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||||
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
|
|
||||||
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||||
group: TimeEntryAggregationType,
|
group: TimeEntryAggregationType,
|
||||||
sub_group: TimeEntryAggregationType,
|
sub_group: TimeEntryAggregationType,
|
||||||
@@ -482,7 +480,6 @@ const DetailedReportResource = z
|
|||||||
client_ids: z.union([z.array(z.string()), z.null()]),
|
client_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
|
|
||||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||||
rounding_type: z.union([z.string(), z.null()]),
|
rounding_type: z.union([z.string(), z.null()]),
|
||||||
rounding_minutes: z.union([z.number(), z.null()]),
|
rounding_minutes: z.union([z.number(), z.null()]),
|
||||||
@@ -920,11 +917,6 @@ const endpoints = makeApi([
|
|||||||
alias: 'deleteOrganization',
|
alias: 'deleteOrganization',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: DestroyWithPasswordRequest,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
@@ -1971,54 +1963,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/organizations/:organization/invoices/:invoice/copy',
|
|
||||||
alias: 'copyInvoice',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: z.object({ reference: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'invoice',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 422,
|
|
||||||
description: `Validation error`,
|
|
||||||
schema: z
|
|
||||||
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/invoices/:invoice',
|
path: '/v1/organizations/:organization/invoices/:invoice',
|
||||||
@@ -3786,11 +3730,6 @@ Users with the permission `time-entries:view:own` can only use this en
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tag_match_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'task_ids',
|
name: 'task_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4172,11 +4111,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tag_match_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'task_ids',
|
name: 'task_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4371,11 +4305,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tag_match_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'task_ids',
|
name: 'task_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4504,11 +4433,6 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
type: 'Query',
|
type: 'Query',
|
||||||
schema: z.array(z.string()).min(1).optional(),
|
schema: z.array(z.string()).min(1).optional(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tag_match_type',
|
|
||||||
type: 'Query',
|
|
||||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'task_ids',
|
name: 'task_ids',
|
||||||
type: 'Query',
|
type: 'Query',
|
||||||
@@ -4670,11 +4594,6 @@ the organization.`,
|
|||||||
description: `This endpoint is independent of the organization.`,
|
description: `This endpoint is independent of the organization.`,
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: DestroyWithPasswordRequest,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: 'user',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { Check, Plus } from '@lucide/vue';
|
import { Check, Plus } from '@lucide/vue';
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@ const searchInput = ref<HTMLElement | null>(null);
|
|||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
function isClientSelected(id: string) {
|
||||||
|
return model.value === id;
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -54,23 +58,15 @@ async function addClientIfNoneExists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' };
|
|
||||||
|
|
||||||
const currentClient = computed(() => {
|
const currentClient = computed(() => {
|
||||||
return props.clients.find((client) => client.id === model.value) ?? NO_CLIENT;
|
return (
|
||||||
|
props.clients.find((client) => client.id === model.value) ?? {
|
||||||
|
id: null,
|
||||||
|
name: 'No Client',
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
type ClientRow = Client | typeof NO_CLIENT;
|
|
||||||
|
|
||||||
// Fold the "No Client" entry in as the first row so the whole list virtualizes through one
|
|
||||||
// ComboboxVirtualizer. NO_CLIENT is a shared constant so currentClient and the row reference
|
|
||||||
// the same object and single-select highlighting still matches.
|
|
||||||
const clientRows = computed<ClientRow[]>(() => [NO_CLIENT, ...filteredClients.value]);
|
|
||||||
|
|
||||||
function clientRowName(row: ClientRow) {
|
|
||||||
return row.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
function updateValue(client: { id: string | null; name: string }) {
|
function updateValue(client: { id: string | null; name: string }) {
|
||||||
@@ -85,56 +81,56 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
:model-value="currentClient"
|
:model-value="currentClient"
|
||||||
class="relative"
|
class="relative"
|
||||||
:ignore-filter="true"
|
@update:model-value="updateValue">
|
||||||
@update:model-value="updateValue"
|
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="searchValue"
|
|
||||||
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a client..." />
|
placeholder="Search for a client..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport
|
<ComboboxViewport
|
||||||
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option: row }"
|
:value="{ id: null, name: 'No Client' }"
|
||||||
:options="clientRows"
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
|
||||||
:estimate-size="32"
|
<span>No Client</span>
|
||||||
:text-content="clientRowName">
|
<span
|
||||||
<ComboboxItem
|
v-if="model === null"
|
||||||
:value="row"
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
<Check class="h-4 w-4" />
|
||||||
:data-client-id="row.id">
|
</span>
|
||||||
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
|
</ComboboxItem>
|
||||||
<span
|
<ComboboxItem
|
||||||
v-if="model === row.id"
|
v-for="client in filteredClients"
|
||||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
:key="client.id"
|
||||||
<Check class="h-4 w-4" />
|
:value="client"
|
||||||
</span>
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
</ComboboxItem>
|
:data-client-id="client.id">
|
||||||
</ComboboxVirtualizer>
|
<span>{{ client.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="isClientSelected(client.id)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</ComboboxItem>
|
||||||
|
<div
|
||||||
|
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||||
|
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
|
@click="addClientIfNoneExists">
|
||||||
|
<Plus class="h-4 w-4 shrink-0" />
|
||||||
|
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||||
|
</div>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
<div
|
|
||||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
|
||||||
class="flex items-center gap-2 rounded-sm mx-1 px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
||||||
@click="addClientIfNoneExists">
|
|
||||||
<Plus class="h-4 w-4 shrink-0" />
|
|
||||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
|
||||||
</div>
|
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
</div>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T">
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, type Ref, ref, watch } from 'vue';
|
||||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||||
import {
|
import {
|
||||||
ComboboxAnchor,
|
ComboboxAnchor,
|
||||||
@@ -9,16 +9,10 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
|
||||||
|
|
||||||
const NONE_ID = 'none';
|
const NONE_ID = 'none';
|
||||||
|
|
||||||
// height of one row (px-2 py-1.5 text-sm → 12px padding + 20px line box).
|
|
||||||
// Rows are uniform single-line, so a fixed size is exact enough for the virtualizer and avoids
|
|
||||||
// any per-row DOM measurement.
|
|
||||||
const ROW_HEIGHT = 32;
|
|
||||||
|
|
||||||
const model = defineModel<string[]>({
|
const model = defineModel<string[]>({
|
||||||
default: [],
|
default: [],
|
||||||
});
|
});
|
||||||
@@ -33,25 +27,20 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
// Pinned on open so rows don't re-sort while toggling; the item list itself stays reactive.
|
const sortedItems = ref<T[]>([]) as Ref<T[]>;
|
||||||
const pinnedSelection = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
searchValue.value = '';
|
searchValue.value = '';
|
||||||
pinnedSelection.value = new Set(model.value);
|
sortedItems.value = [...props.items].sort((a, b) => {
|
||||||
|
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
||||||
|
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
||||||
|
if (aSelected !== bSelected) return aSelected - bSelected;
|
||||||
|
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedItems = computed(() => {
|
|
||||||
return [...props.items].sort((a, b) => {
|
|
||||||
const aSelected = pinnedSelection.value.has(props.getKeyFromItem(a)) ? 0 : 1;
|
|
||||||
const bSelected = pinnedSelection.value.has(props.getKeyFromItem(b)) ? 0 : 1;
|
|
||||||
if (aSelected !== bSelected) return aSelected - bSelected;
|
|
||||||
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const search = searchValue.value.toLowerCase().trim();
|
const search = searchValue.value.toLowerCase().trim();
|
||||||
if (!search) return sortedItems.value;
|
if (!search) return sortedItems.value;
|
||||||
@@ -67,23 +56,6 @@ const showNoItem = computed(() => {
|
|||||||
return props.noItemLabel.toLowerCase().includes(search);
|
return props.noItemLabel.toLowerCase().includes(search);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A single flat list for the virtualizer. The optional "no item" entry is folded in as the
|
|
||||||
// first row so the whole list (including it) is virtualized through one ComboboxVirtualizer.
|
|
||||||
type Row = { kind: 'none' } | { kind: 'item'; item: T };
|
|
||||||
|
|
||||||
const rows = computed<Row[]>(() => {
|
|
||||||
const itemRows = filteredItems.value.map((item): Row => ({ kind: 'item', item }));
|
|
||||||
return showNoItem.value ? [{ kind: 'none' }, ...itemRows] : itemRows;
|
|
||||||
});
|
|
||||||
|
|
||||||
function keyForRow(row: Row): string {
|
|
||||||
return row.kind === 'none' ? NONE_ID : props.getKeyFromItem(row.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameForRow(row: Row): string {
|
|
||||||
return row.kind === 'none' ? (props.noItemLabel ?? '') : props.getNameForItem(row.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleItem(id: string) {
|
function toggleItem(id: string) {
|
||||||
if (model.value.includes(id)) {
|
if (model.value.includes(id)) {
|
||||||
model.value = model.value.filter((itemId) => itemId !== id);
|
model.value = model.value.filter((itemId) => itemId !== id);
|
||||||
@@ -102,44 +74,46 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
class="p-2"
|
class="p-2"
|
||||||
:ignore-filter="true"
|
:filter-function="(val: string[]) => val">
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
v-model="searchValue"
|
|
||||||
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||||
:placeholder="searchPlaceholder" />
|
:placeholder="searchPlaceholder" />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent
|
<ComboboxContent
|
||||||
:dismiss-able="false"
|
:dismiss-able="false"
|
||||||
position="inline"
|
position="inline"
|
||||||
class="mt-2 min-w-60 max-w-80">
|
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
|
||||||
<ComboboxViewport class="max-h-60 overflow-y-auto">
|
<ComboboxViewport>
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option }"
|
v-if="showNoItem"
|
||||||
:options="rows"
|
:value="NONE_ID"
|
||||||
:estimate-size="ROW_HEIGHT"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||||
:text-content="nameForRow">
|
@select.prevent="toggleItem(NONE_ID)">
|
||||||
<ComboboxItem
|
<Checkbox
|
||||||
:value="keyForRow(option)"
|
:checked="model.includes(NONE_ID)"
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
aria-hidden="true"
|
||||||
@select.prevent="toggleItem(keyForRow(option))">
|
:tabindex="-1"
|
||||||
<Checkbox
|
class="pointer-events-none" />
|
||||||
:checked="model.includes(keyForRow(option))"
|
<span class="truncate">{{ noItemLabel }}</span>
|
||||||
aria-hidden="true"
|
</ComboboxItem>
|
||||||
:tabindex="-1"
|
<ComboboxItem
|
||||||
class="pointer-events-none" />
|
v-for="item in filteredItems"
|
||||||
<span class="truncate">{{ nameForRow(option) }}</span>
|
:key="getKeyFromItem(item)"
|
||||||
</ComboboxItem>
|
:value="getKeyFromItem(item)"
|
||||||
</ComboboxVirtualizer>
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||||
|
@select.prevent="toggleItem(getKeyFromItem(item))">
|
||||||
|
<Checkbox
|
||||||
|
:checked="model.includes(getKeyFromItem(item))"
|
||||||
|
aria-hidden="true"
|
||||||
|
:tabindex="-1"
|
||||||
|
class="pointer-events-none" />
|
||||||
|
<span class="truncate">{{ getNameForItem(item) }}</span>
|
||||||
|
</ComboboxItem>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||||
<div class="flex space-x-3 items-center px-3 py-1.5 min-w-0">
|
<div class="flex space-x-3 items-center px-3 py-1.5">
|
||||||
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full shrink-0"></div>
|
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
|
||||||
<span class="truncate">{{ name }}</span>
|
<span>{{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="actions"></slot>
|
<slot name="actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user