mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
22 Commits
v0.20.0
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a83d96b79 | ||
|
|
c23f09fb2d | ||
|
|
a0262addb7 | ||
|
|
b4911ebddd | ||
|
|
3ade20887b | ||
|
|
4943a38ffe | ||
|
|
12e355ff26 | ||
|
|
01b60c0f6a | ||
|
|
927da7dce9 | ||
|
|
c89fa87b0f | ||
|
|
02cf600f43 | ||
|
|
98a460725f | ||
|
|
95645ddd91 | ||
|
|
1de985b577 | ||
|
|
d54296e66a | ||
|
|
95ddbf9ead | ||
|
|
70646a0dd4 | ||
|
|
5b12c09747 | ||
|
|
24023353f2 | ||
|
|
720d20c10e | ||
|
|
82ea9af8b5 | ||
|
|
169d522da0 |
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -141,7 +141,7 @@ jobs:
|
|||||||
${{ env.DOCKER_REPO }}
|
${{ env.DOCKER_REPO }}
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to solidtime OnPremise Registry"
|
- name: "Login to solidtime OnPremise Registry"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: registry.on-premise.solidtime.io
|
registry: registry.on-premise.solidtime.io
|
||||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||||
|
|||||||
4
.github/workflows/build-private.yml
vendored
4
.github/workflows/build-private.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
|||||||
- name: "Install dependencies in billing extension"
|
- name: "Install dependencies in billing extension"
|
||||||
uses: php-actions/composer@v6
|
uses: php-actions/composer@v6
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
with:
|
with:
|
||||||
working_dir: "extensions/Billing"
|
working_dir: "extensions/Billing"
|
||||||
command: install
|
command: install
|
||||||
@@ -177,7 +177,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.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: rg.fr-par.scw.cloud/solidtime
|
registry: rg.fr-par.scw.cloud/solidtime
|
||||||
username: nologin
|
username: nologin
|
||||||
|
|||||||
8
.github/workflows/build-public.yml
vendored
8
.github/workflows/build-public.yml
vendored
@@ -117,13 +117,13 @@ jobs:
|
|||||||
${{ 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.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
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.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -177,13 +177,13 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v4.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
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.5.2
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
136
.github/workflows/phpunit-extensions.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
name: PHPUnit Tests - Extensions
|
||||||
|
on: push
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
phpunit-extensions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
postgres_version: [ 15, 16, 17 ]
|
||||||
|
|
||||||
|
services:
|
||||||
|
pgsql_test:
|
||||||
|
image: postgres:${{ matrix.postgres_version }}
|
||||||
|
env:
|
||||||
|
PGPASSWORD: 'root'
|
||||||
|
POSTGRES_DB: 'laravel'
|
||||||
|
POSTGRES_USER: 'root'
|
||||||
|
POSTGRES_PASSWORD: 'root'
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
options: >-
|
||||||
|
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: "Setup PHP"
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
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"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
|
||||||
|
path: extensions/Billing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in billing extension"
|
||||||
|
working-directory: extensions/Billing
|
||||||
|
env:
|
||||||
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in billing extension"
|
||||||
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout services extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.services_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.services_ref }}
|
||||||
|
path: extensions/Services
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in services extension"
|
||||||
|
working-directory: extensions/Services
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in services extension"
|
||||||
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout invoicing extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
|
||||||
|
path: extensions/Invoicing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in invoicing extension"
|
||||||
|
working-directory: extensions/Invoicing
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Run composer install"
|
||||||
|
run: composer install -n --prefer-dist --ignore-platform-reqs
|
||||||
|
|
||||||
|
- name: "Activate billing extension"
|
||||||
|
run: php artisan module:enable Billing
|
||||||
|
|
||||||
|
- name: "Activate services extension"
|
||||||
|
run: php artisan module:enable Services
|
||||||
|
|
||||||
|
- name: "Activate invoicing extension"
|
||||||
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Build Frontend"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: "Prepare Laravel Application"
|
||||||
|
run: |
|
||||||
|
cp .env.ci .env
|
||||||
|
php artisan key:generate
|
||||||
|
php artisan passport:keys
|
||||||
|
|
||||||
|
- name: "Run PHPUnit"
|
||||||
|
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -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@v7.1.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
37
app/Auth/ActiveUserProvider.php
Normal file
37
app/Auth/ActiveUserProvider.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\EloquentUserProvider;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User provider that only resolves non-placeholder users.
|
||||||
|
*
|
||||||
|
* Placeholder users are created by imports and when members are removed from an
|
||||||
|
* organization. They can share an email address with a real user, so resolving a user by
|
||||||
|
* email can return a placeholder instead of the real account. The login flow filters them
|
||||||
|
* out explicitly, but the password broker and the guard credential checks (for example the
|
||||||
|
* password confirmation) resolve users through the configured user provider.
|
||||||
|
*
|
||||||
|
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
|
||||||
|
* built-in one for every provider in config/auth.php.
|
||||||
|
*/
|
||||||
|
class ActiveUserProvider extends EloquentUserProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Model|null $model
|
||||||
|
* @return Builder<Model>
|
||||||
|
*/
|
||||||
|
#[\Override]
|
||||||
|
protected function newModelQuery($model = null): Builder
|
||||||
|
{
|
||||||
|
$query = parent::newModelQuery($model);
|
||||||
|
$query->getQuery()->where('is_placeholder', '=', false);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class ImportRequest extends BaseFormRequest
|
|||||||
'data' => [
|
'data' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
|
'max:'.config('import.max_data_size'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string|null $pending_email
|
* @property string|null $pending_email
|
||||||
* @property Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
|
* @property string|null $remember_token
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
|
* @property string|null $two_factor_recovery_codes
|
||||||
|
* @property Carbon|null $two_factor_confirmed_at
|
||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
@@ -150,7 +153,9 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
return $this->is_placeholder === false
|
||||||
|
&& in_array($this->email, config('auth.super_admins', []), true)
|
||||||
|
&& $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Auth\ActiveUserProvider;
|
||||||
use App\Models\Passport\AuthCode;
|
use App\Models\Passport\AuthCode;
|
||||||
use App\Models\Passport\Client;
|
use App\Models\Passport\Client;
|
||||||
use App\Models\Passport\RefreshToken;
|
use App\Models\Passport\RefreshToken;
|
||||||
use App\Models\Passport\Token;
|
use App\Models\Passport\Token;
|
||||||
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
@@ -26,6 +29,13 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Replaces the built-in eloquent user provider, so that no authentication flow can
|
||||||
|
// resolve a placeholder user. The driver name is kept, because Passport recognizes
|
||||||
|
// only providers that are configured with the driver "eloquent".
|
||||||
|
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
|
||||||
|
return new ActiveUserProvider($app->make('hash'), $config['model']);
|
||||||
|
});
|
||||||
|
|
||||||
// define scopes for passport tokens
|
// define scopes for passport tokens
|
||||||
Passport::tokensCan([
|
Passport::tokensCan([
|
||||||
'create' => 'Create resources',
|
'create' => 'Create resources',
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
|
|||||||
use App\Service\Import\Importers\ImporterProvider;
|
use App\Service\Import\Importers\ImporterProvider;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\ReportDto;
|
use App\Service\Import\Importers\ReportDto;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ImportService
|
class ImportService
|
||||||
{
|
{
|
||||||
@@ -25,8 +22,6 @@ class ImportService
|
|||||||
/** @var ImporterContract $importer */
|
/** @var ImporterContract $importer */
|
||||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
Storage::disk(config('filesystems.default'))
|
|
||||||
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
|
||||||
|
|
||||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
], [
|
], [
|
||||||
'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' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||||
@@ -54,6 +54,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
||||||
$tasks = explode(', ', $record[$tasksKey]);
|
$tasks = explode(', ', $record[$tasksKey]);
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
|
$this->checkTaskNameLength($task);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task,
|
'name' => $task,
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
@@ -83,7 +84,6 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'Project',
|
'Project',
|
||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
|
||||||
];
|
];
|
||||||
foreach ($requiredFields as $requiredField) {
|
foreach ($requiredFields as $requiredField) {
|
||||||
if (! in_array($requiredField, $header, true)) {
|
if (! in_array($requiredField, $header, true)) {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
|
||||||
|
$this->checkTaskNameLength($record[$taskKey]);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record[$taskKey],
|
'name' => $record[$taskKey],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
@@ -227,7 +228,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
'Project',
|
'Project',
|
||||||
'Description',
|
'Description',
|
||||||
'User',
|
'User',
|
||||||
'Group',
|
|
||||||
'Email',
|
'Email',
|
||||||
'Tags',
|
'Tags',
|
||||||
'Start Date',
|
'Start Date',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
|
|
||||||
abstract class DefaultImporter implements ImporterContract
|
abstract class DefaultImporter implements ImporterContract
|
||||||
{
|
{
|
||||||
|
protected const TASK_NAME_MAX_LENGTH = 500;
|
||||||
|
|
||||||
protected Organization $organization;
|
protected Organization $organization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,6 +183,16 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
$this->billableRateService = app(BillableRateService::class);
|
$this->billableRateService = app(BillableRateService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
protected function checkTaskNameLength(string $taskName): void
|
||||||
|
{
|
||||||
|
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
|
||||||
|
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[\Override]
|
#[\Override]
|
||||||
public function getReport(): ReportDto
|
public function getReport(): ReportDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['task'] !== '') {
|
if ($record['task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['task'],
|
'name' => $record['task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['Task'] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['Task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['Task'],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use Illuminate\Support\Str;
|
|||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
class SolidtimeImporter extends DefaultImporter
|
class SolidtimeImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -34,16 +33,10 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
$zip = new ZipArchive;
|
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
|
||||||
if ($res !== true) {
|
|
||||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
|
||||||
}
|
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
$zip->extractTo($temporaryDirectory->path());
|
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||||
throw new ImportException('File "meta.json" missing in ZIP');
|
throw new ImportException('File "meta.json" missing in ZIP');
|
||||||
@@ -206,6 +199,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
if ($projectId === null) {
|
if ($projectId === null) {
|
||||||
throw new Exception('Project does not exist');
|
throw new Exception('Project does not exist');
|
||||||
}
|
}
|
||||||
|
$this->checkTaskNameLength($task['name']);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task['name'],
|
'name' => $task['name'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use Illuminate\Support\Str;
|
|||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
class TogglDataImporter extends DefaultImporter
|
class TogglDataImporter extends DefaultImporter
|
||||||
{
|
{
|
||||||
@@ -26,16 +25,10 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
$temporaryDirectoryZip = null;
|
$temporaryDirectoryZip = null;
|
||||||
$temporaryDirectory = null;
|
$temporaryDirectory = null;
|
||||||
try {
|
try {
|
||||||
$zip = new ZipArchive;
|
|
||||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
|
||||||
if ($res !== true) {
|
|
||||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
|
||||||
}
|
|
||||||
$temporaryDirectory = TemporaryDirectory::make();
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
$zip->extractTo($temporaryDirectory->path());
|
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
|
||||||
$zip->close();
|
|
||||||
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
if (! file_exists($temporaryDirectory->path('clients.json'))) {
|
||||||
throw new ImportException('File "clients.json" missing in ZIP');
|
throw new ImportException('File "clients.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
@@ -160,9 +153,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
foreach ($projectMembers as $projectMember) {
|
foreach ($projectMembers as $projectMember) {
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
||||||
|
if ($userId === null) {
|
||||||
|
throw new Exception('User does not exist');
|
||||||
|
}
|
||||||
|
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
|
||||||
|
if ($memberId === null) {
|
||||||
|
throw new Exception('Member does not exist');
|
||||||
|
}
|
||||||
$this->projectMemberImportHelper->getKey([
|
$this->projectMemberImportHelper->getKey([
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
|
'member_id' => $memberId,
|
||||||
], [
|
], [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
|
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
|
||||||
@@ -189,6 +189,7 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
if ($projectId === null) {
|
if ($projectId === null) {
|
||||||
throw new Exception('Project does not exist');
|
throw new Exception('Project does not exist');
|
||||||
}
|
}
|
||||||
|
$this->checkTaskNameLength($task->name);
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task->name,
|
'name' => $task->name,
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($record['Task'] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
|
$this->checkTaskNameLength($record['Task']);
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record['Task'],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
|
|||||||
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts uploaded ZIP archives with limits on file count, total uncompressed
|
||||||
|
* size and entry paths, so a small malicious archive can not fill the disk
|
||||||
|
* (decompression bomb) or write outside the target directory (zip slip).
|
||||||
|
*/
|
||||||
|
class ZipImportHelper
|
||||||
|
{
|
||||||
|
private const int CHUNK_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
public function extract(string $zipPath, string $targetPath): void
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$res = $zip->open($zipPath, ZipArchive::RDONLY);
|
||||||
|
if ($res !== true) {
|
||||||
|
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$maxFiles = (int) config('import.zip_max_files');
|
||||||
|
$maxUncompressedSize = (int) config('import.zip_max_uncompressed_size');
|
||||||
|
|
||||||
|
if ($zip->numFiles > $maxFiles) {
|
||||||
|
throw new ImportException('ZIP contains too many files, maximum is '.$maxFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the sizes declared in the archive before writing anything to disk
|
||||||
|
$declaredSize = 0;
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$stat = $zip->statIndex($index);
|
||||||
|
if ($stat === false) {
|
||||||
|
throw new ImportException('Invalid ZIP entry');
|
||||||
|
}
|
||||||
|
$this->validateEntryName($stat['name']);
|
||||||
|
$declaredSize += $stat['size'];
|
||||||
|
if ($declaredSize > $maxUncompressedSize) {
|
||||||
|
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The declared sizes can be forged, so the written bytes are counted as well
|
||||||
|
$writtenSize = 0;
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$stat = $zip->statIndex($index);
|
||||||
|
if ($stat === false) {
|
||||||
|
throw new ImportException('Invalid ZIP entry');
|
||||||
|
}
|
||||||
|
$name = $stat['name'];
|
||||||
|
$entryPath = $targetPath.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
|
if (str_ends_with($name, '/')) {
|
||||||
|
$this->ensureDirectoryExists($entryPath);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->ensureDirectoryExists(dirname($entryPath));
|
||||||
|
|
||||||
|
$stream = $zip->getStreamIndex($index);
|
||||||
|
if ($stream === false) {
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be read');
|
||||||
|
}
|
||||||
|
$target = fopen($entryPath, 'wb');
|
||||||
|
if ($target === false) {
|
||||||
|
fclose($stream);
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be extracted');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
while (! feof($stream)) {
|
||||||
|
$chunk = fread($stream, self::CHUNK_SIZE);
|
||||||
|
if ($chunk === false) {
|
||||||
|
throw new ImportException('ZIP entry "'.$name.'" can not be read');
|
||||||
|
}
|
||||||
|
$writtenSize += strlen($chunk);
|
||||||
|
if ($writtenSize > $maxUncompressedSize) {
|
||||||
|
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
|
||||||
|
}
|
||||||
|
fwrite($target, $chunk);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($target);
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function validateEntryName(string $name): void
|
||||||
|
{
|
||||||
|
if ($name === '' || str_contains($name, "\0") || str_contains($name, '\\') || str_starts_with($name, '/')) {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
if (preg_match('/^[a-zA-Z]:/', $name) === 1) {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
foreach (explode('/', rtrim($name, '/')) as $segment) {
|
||||||
|
if ($segment === '' || $segment === '..') {
|
||||||
|
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function ensureDirectoryExists(string $path): void
|
||||||
|
{
|
||||||
|
if (is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! mkdir($path, 0700, true) && ! is_dir($path)) {
|
||||||
|
throw new ImportException('Directory "'.$path.'" can not be created');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,7 +218,16 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
$placeholderUser->current_team_id = $member->organization_id;
|
// Reset authentication relevant properties on the placeholder user
|
||||||
|
$placeholderUser->password = null;
|
||||||
|
$placeholderUser->remember_token = null;
|
||||||
|
$placeholderUser->two_factor_secret = null;
|
||||||
|
$placeholderUser->two_factor_recovery_codes = null;
|
||||||
|
$placeholderUser->two_factor_confirmed_at = null;
|
||||||
|
$placeholderUser->email_verified_at = null;
|
||||||
|
$placeholderUser->pending_email = null;
|
||||||
|
$placeholderUser->current_team_id = null;
|
||||||
|
$placeholderUser->profile_photo_path = null;
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
|
|||||||
34
config/import.php
Normal file
34
config/import.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Import payload limit
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Maximum length of the base64 encoded "data" field of an import request in
|
||||||
|
| bytes. Requests with a larger payload are rejected with a validation error.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'max_data_size' => (int) (env('IMPORT_MAX_DATA_SIZE') ?: 50 * 1024 * 1024),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ZIP extraction limits
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Limits applied to ZIP based importers before and during extraction to
|
||||||
|
| protect the instance against decompression bombs. The uncompressed size
|
||||||
|
| is the sum of all files in the archive in bytes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
|
||||||
|
|
||||||
|
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Placeholder users used to be created as a full copy of the user they were made from,
|
||||||
|
* which included the credentials and the account state of that user. A placeholder is a
|
||||||
|
* stand-in for a person in one organization, not an account, and the row shares the email
|
||||||
|
* address with the real account, so these values are removed from the placeholders that
|
||||||
|
* already exist. The organization a placeholder belongs to is recorded on its member row.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('users')
|
||||||
|
->where('is_placeholder', '=', true)
|
||||||
|
->where(function (Builder $builder): void {
|
||||||
|
$builder->whereNotNull('password')
|
||||||
|
->orWhereNotNull('remember_token')
|
||||||
|
->orWhereNotNull('two_factor_secret')
|
||||||
|
->orWhereNotNull('two_factor_recovery_codes')
|
||||||
|
->orWhereNotNull('two_factor_confirmed_at')
|
||||||
|
->orWhereNotNull('email_verified_at')
|
||||||
|
->orWhereNotNull('pending_email')
|
||||||
|
->orWhereNotNull('current_team_id')
|
||||||
|
->orWhereNotNull('profile_photo_path');
|
||||||
|
})
|
||||||
|
->update([
|
||||||
|
'password' => null,
|
||||||
|
'remember_token' => null,
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'pending_email' => null,
|
||||||
|
'current_team_id' => null,
|
||||||
|
'profile_photo_path' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3
|
||||||
ARG FRANKENPHP_VERSION=1.8
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
ARG COMPOSER_VERSION=2.8
|
ARG COMPOSER_VERSION=2.8
|
||||||
ARG BUN_VERSION="latest"
|
|
||||||
ARG APP_ENV
|
ARG APP_ENV
|
||||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||||
|
|
||||||
@@ -16,13 +15,13 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
--with github.com/dunglas/caddy-cbrotli
|
--with github.com/dunglas/caddy-cbrotli
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||||
|
|
||||||
@@ -32,26 +31,28 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL"
|
LABEL org.opencontainers.image.licenses="AGPL"
|
||||||
|
|
||||||
ARG WWWUSER=1000
|
ARG USER_ID=1000
|
||||||
ARG WWWGROUP=1000
|
ARG GROUP_ID=1000
|
||||||
ARG TZ=UTC
|
ARG TZ=UTC
|
||||||
ARG APP_DIR=/var/www/html
|
|
||||||
ARG APP_ENV
|
|
||||||
ARG APP_HOST
|
|
||||||
ARG DOCKER_FILES_BASE_PATH
|
ARG DOCKER_FILES_BASE_PATH
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
TERM=xterm-color \
|
TERM=xterm-color \
|
||||||
OCTANE_SERVER=frankenphp \
|
OCTANE_SERVER=frankenphp \
|
||||||
TZ=${TZ} \
|
TZ=${TZ} \
|
||||||
USER=octane \
|
LANG=C.UTF-8 \
|
||||||
ROOT=${APP_DIR} \
|
USER=laravel \
|
||||||
APP_ENV=${APP_ENV} \
|
ROOT=/var/www/html \
|
||||||
|
APP_ENV=production \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
COMPOSER_FUND=0 \
|
COMPOSER_FUND=0 \
|
||||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
WITH_HORIZON=false \
|
||||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
WITH_SCHEDULER=false \
|
||||||
SERVER_NAME=${APP_HOST}
|
WITH_REVERB=false \
|
||||||
|
WITH_SSR=false
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||||
|
|
||||||
WORKDIR ${ROOT}
|
WORKDIR ${ROOT}
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
|||||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||||
&& echo ${TZ} > /etc/timezone
|
&& echo ${TZ} > /etc/timezone
|
||||||
|
|
||||||
|
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||||
|
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||||
|
|
||||||
RUN apt-get update; \
|
RUN apt-get update; \
|
||||||
apt-get upgrade -yqq; \
|
apt-get upgrade -yqq; \
|
||||||
apt-get install -yqq --no-install-recommends --show-progress \
|
apt-get install -yqq --no-install-recommends --show-progress \
|
||||||
@@ -68,40 +72,36 @@ RUN apt-get update; \
|
|||||||
wget \
|
wget \
|
||||||
vim \
|
vim \
|
||||||
git \
|
git \
|
||||||
|
unzip \
|
||||||
ncdu \
|
ncdu \
|
||||||
procps \
|
procps \
|
||||||
unzip \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
supervisor \
|
supervisor \
|
||||||
libsodium-dev \
|
libsodium-dev \
|
||||||
libbrotli-dev \
|
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||||
# Install PHP extensions (included with dunglas/frankenphp)
|
|
||||||
&& install-php-extensions \
|
&& install-php-extensions \
|
||||||
|
apcu \
|
||||||
bz2 \
|
bz2 \
|
||||||
pcntl \
|
pcntl \
|
||||||
mbstring \
|
mbstring \
|
||||||
bcmath \
|
bcmath \
|
||||||
sockets \
|
sockets \
|
||||||
pgsql \
|
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
opcache \
|
opcache \
|
||||||
exif \
|
exif \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
zip \
|
zip \
|
||||||
uv \
|
uv \
|
||||||
vips \
|
|
||||||
intl \
|
intl \
|
||||||
gd \
|
gd \
|
||||||
redis \
|
redis \
|
||||||
rdkafka \
|
rdkafka \
|
||||||
memcached \
|
ffi \
|
||||||
igbinary \
|
|
||||||
ldap \
|
ldap \
|
||||||
&& apt-get -y autoremove \
|
&& apt-get -y autoremove \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& docker-php-source delete \
|
&& docker-php-source delete \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||||
&& rm /var/log/lastlog /var/log/faillog
|
|
||||||
|
|
||||||
RUN arch="$(uname -m)" \
|
RUN arch="$(uname -m)" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -111,106 +111,64 @@ RUN arch="$(uname -m)" \
|
|||||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||||
-O /usr/bin/supercronic \
|
-O /usr/bin/supercronic \
|
||||||
&& chmod +x /usr/bin/supercronic \
|
&& chmod +x /usr/bin/supercronic \
|
||||||
&& mkdir -p /etc/supercronic \
|
&& mkdir -p /etc/supercronic \
|
||||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||||
|
|
||||||
RUN userdel --remove --force www-data \
|
RUN userdel --remove --force www-data \
|
||||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
|
||||||
&& setcap -r /usr/local/bin/frankenphp
|
|
||||||
|
|
||||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
|
||||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
|
||||||
|
|
||||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||||
|
|
||||||
USER ${USER}
|
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
|
||||||
|
#COPY --link composer.* ./
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
|
||||||
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM base AS common
|
|
||||||
#
|
|
||||||
#USER ${USER}
|
|
||||||
#
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
|
||||||
#
|
|
||||||
#RUN composer install \
|
#RUN composer install \
|
||||||
# --no-dev \
|
# --no-dev \
|
||||||
# --no-interaction \
|
# --no-interaction \
|
||||||
# --no-autoloader \
|
# --no-autoloader \
|
||||||
# --no-ansi \
|
# --no-ansi \
|
||||||
# --no-scripts \
|
# --no-scripts \
|
||||||
|
# --no-progress \
|
||||||
# --audit
|
# --audit
|
||||||
|
|
||||||
###########################################
|
|
||||||
# Build frontend assets with Bun
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM oven/bun:${BUN_VERSION} AS build
|
|
||||||
#
|
|
||||||
#ARG APP_ENV
|
|
||||||
#
|
|
||||||
#ENV ROOT=/var/www/html \
|
|
||||||
# APP_ENV=${APP_ENV} \
|
|
||||||
# NODE_ENV=${APP_ENV:-production}
|
|
||||||
#
|
|
||||||
#WORKDIR ${ROOT}
|
|
||||||
#
|
|
||||||
#COPY --link package.json bun.lock* ./
|
#COPY --link package.json bun.lock* ./
|
||||||
#
|
|
||||||
#RUN bun install --frozen-lockfile
|
#RUN bun install --frozen-lockfile
|
||||||
#
|
|
||||||
#COPY --link . .
|
|
||||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
|
||||||
#
|
|
||||||
#RUN bun run build
|
|
||||||
|
|
||||||
###########################################
|
COPY --link . .
|
||||||
|
|
||||||
#FROM common AS runner
|
|
||||||
|
|
||||||
USER ${USER}
|
|
||||||
|
|
||||||
ENV WITH_HORIZON=false \
|
|
||||||
WITH_SCHEDULER=false \
|
|
||||||
WITH_REVERB=false
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
|
||||||
|
|
||||||
RUN test -z "$(find . -name .git -print -quit)"
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
storage/framework/{sessions,views,cache,testing} \
|
storage/framework/{sessions,views,cache,testing} \
|
||||||
storage/logs \
|
storage/logs \
|
||||||
bootstrap/cache && chmod -R a+rw storage
|
bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||||
|
|
||||||
#RUN composer install \
|
RUN composer dump-autoload \
|
||||||
# --classmap-authoritative \
|
--optimize \
|
||||||
# --no-interaction \
|
--apcu \
|
||||||
# --no-ansi \
|
--no-dev
|
||||||
# --no-dev \
|
|
||||||
# && composer clear-cache
|
|
||||||
|
|
||||||
RUN cat .env
|
#RUN bun run build
|
||||||
#RUN php artisan env
|
|
||||||
|
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||||
|
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
USER ${USER}
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
#EXPOSE 2019
|
||||||
|
#EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
ENTRYPOINT ["start-container"]
|
||||||
|
|
||||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ elif [ "${container_mode}" = "reverb" ]; then
|
|||||||
echo "Healthcheck failed."
|
echo "Healthcheck failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
elif [ "${container_mode}" = "ssr" ]; then
|
||||||
|
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Healthcheck failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
elif [ "${container_mode}" = "worker" ]; then
|
elif [ "${container_mode}" = "worker" ]; then
|
||||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||||
|
|
||||||
|
log {
|
||||||
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_https off
|
||||||
|
|
||||||
|
skip_install_trust
|
||||||
|
|
||||||
frankenphp {
|
frankenphp {
|
||||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||||
}
|
}
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
{$CADDY_SERVER_SERVER_NAME} {
|
{$CADDY_SERVER_SERVER_NAME} {
|
||||||
log {
|
log {
|
||||||
level WARN
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
|
||||||
format filter {
|
format filter {
|
||||||
wrap {$CADDY_SERVER_LOGGER}
|
wrap {$CADDY_SERVER_LOGGER}
|
||||||
@@ -60,7 +68,6 @@
|
|||||||
error @rejected 401
|
error @rejected 401
|
||||||
|
|
||||||
php_server {
|
php_server {
|
||||||
index frankenphp-worker.php
|
|
||||||
try_files {path} frankenphp-worker.php
|
try_files {path} frankenphp-worker.php
|
||||||
resolve_root_symlink
|
resolve_root_symlink
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,18 @@
|
|||||||
[program:octane]
|
[program:octane]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
priority = 1
|
priority = 1
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopwaitsecs = 30
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
environment = LARAVEL_OCTANE = "1"
|
environment = LARAVEL_OCTANE = "1"
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
stderr_logfile_maxbytes = 0
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
[program:horizon]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan horizon
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 3
|
|
||||||
autostart = %(ENV_WITH_HORIZON)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
stopwaitsecs = 3600
|
|
||||||
|
|
||||||
[program:scheduler]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = supercronic -overlapping /etc/supercronic/laravel
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:clear-scheduler-cache]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = false
|
|
||||||
startsecs = 0
|
|
||||||
startretries = 1
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:reverb]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 2
|
|
||||||
autostart = %(ENV_WITH_REVERB)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
minfds = 10000
|
|
||||||
|
|
||||||
[include]
|
[include]
|
||||||
files = /etc/supervisord.conf
|
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
post_max_size = 100M
|
post_max_size = 100M
|
||||||
upload_max_filesize = 100M
|
upload_max_filesize = 100M
|
||||||
expose_php = 0
|
expose_php = 0
|
||||||
realpath_cache_size = 16M
|
realpath_cache_size = 32M
|
||||||
realpath_cache_ttl = 360
|
realpath_cache_ttl = 720
|
||||||
|
memory_limit = 256M
|
||||||
max_input_time = 5
|
max_input_time = 5
|
||||||
register_argc_argv = 0
|
register_argc_argv = 0
|
||||||
date.timezone = ${TZ:-UTC}
|
date.timezone = ${TZ:-UTC}
|
||||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
|||||||
[Opcache]
|
[Opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 1
|
opcache.enable_cli = 1
|
||||||
opcache.memory_consumption = 256M
|
opcache.memory_consumption = 256
|
||||||
opcache.use_cwd = 0
|
opcache.use_cwd = 0
|
||||||
|
opcache.save_comments = 1
|
||||||
opcache.max_file_size = 0
|
opcache.max_file_size = 0
|
||||||
opcache.max_accelerated_files = 32531
|
opcache.max_accelerated_files = 32531
|
||||||
opcache.validate_timestamps = 0
|
opcache.validate_timestamps = 0
|
||||||
opcache.file_update_protection = 0
|
opcache.file_update_protection = 0
|
||||||
opcache.interned_strings_buffer = 16
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
opcache.file_cache_consistency_checks = 0
|
||||||
|
opcache.file_cache = /tmp/opcache-file-cache
|
||||||
|
|
||||||
[JIT]
|
[JIT]
|
||||||
opcache.jit_buffer_size = 128M
|
opcache.jit_buffer_size = 128M
|
||||||
opcache.jit = function
|
opcache.jit = tracing
|
||||||
opcache.jit_prof_threshold = 0.001
|
opcache.jit_hot_loop = 16
|
||||||
|
opcache.jit_hot_func = 32
|
||||||
|
opcache.jit_hot_return = 4
|
||||||
opcache.jit_max_root_traces = 2048
|
opcache.jit_max_root_traces = 2048
|
||||||
opcache.jit_max_side_traces = 256
|
opcache.jit_max_side_traces = 256
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon = true
|
nodaemon = true
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
logfile = /var/log/supervisor/supervisord.log
|
|
||||||
pidfile = /var/run/supervisord.pid
|
|
||||||
|
|
||||||
[supervisorctl]
|
[supervisorctl]
|
||||||
|
|
||||||
@@ -10,4 +8,4 @@ pidfile = /var/run/supervisord.pid
|
|||||||
port = 127.0.0.1:9001
|
port = 127.0.0.1:9001
|
||||||
|
|
||||||
[rpcinterface:supervisor]
|
[rpcinterface:supervisor]
|
||||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan horizon
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = true
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = /dev/stdout
|
||||||
|
stdout_logfile_maxbytes = 0
|
||||||
|
stderr_logfile = /dev/stderr
|
||||||
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
|
[include]
|
||||||
|
files = /etc/supervisord.conf
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[program:horizon]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan horizon
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 4
|
||||||
|
autostart = %(ENV_WITH_HORIZON)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
stopwaitsecs = 3600
|
||||||
|
|
||||||
|
[program:scheduler]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = supercronic -overlapping /etc/supercronic/laravel
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:clear-scheduler-cache]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = false
|
||||||
|
startsecs = 0
|
||||||
|
startretries = 1
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:reverb]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 3
|
||||||
|
autostart = %(ENV_WITH_REVERB)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
minfds = 10000
|
||||||
|
|
||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 2
|
||||||
|
autostart = %(ENV_WITH_SSR)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
@@ -4,6 +4,8 @@ command = %(ENV_WORKER_COMMAND)s
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
|
|||||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
|
|
||||||
// Click the "next" button to navigate forward
|
// Navigate between named periods.
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
// Click the "prev" button to navigate back
|
|
||||||
await page.getByRole('button', { name: 'Previous' }).click();
|
await page.getByRole('button', { name: 'Previous' }).click();
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
|
||||||
|
|
||||||
// Navigate forward first, then click today
|
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
|
|
||||||
|
// Navigate forward first, then use the range button to return to today.
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
|
||||||
await page.getByRole('button', { name: 'today' }).click();
|
await page.getByRole('button', { name: 'today' }).click();
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ import {
|
|||||||
// Each test registers a new user and creates test data via API
|
// Each test registers a new user and creates test data via API
|
||||||
test.describe.configure({ timeout: 30000 });
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
|
test('date range picker labels and navigates reporting periods', async ({ page }) => {
|
||||||
|
await goToReporting(page);
|
||||||
|
|
||||||
|
const range = page.getByTestId('date_range_picker_display');
|
||||||
|
const previous = page.getByTestId('date_range_picker_previous');
|
||||||
|
const next = page.getByTestId('date_range_picker_next');
|
||||||
|
|
||||||
|
await expect(range).toContainText('Last 14 Days');
|
||||||
|
await expect(next).toBeDisabled();
|
||||||
|
|
||||||
|
await Promise.all([waitForReportingUpdate(page), previous.click()]);
|
||||||
|
await expect(range).not.toContainText('Last 14 Days');
|
||||||
|
|
||||||
|
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
|
||||||
|
// does not necessarily trigger another aggregate request.
|
||||||
|
await next.click();
|
||||||
|
await expect(range).toContainText('Last 14 Days');
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Project Multiselect Dropdown Tests
|
// Project Multiselect Dropdown Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1579,6 +1579,57 @@ test('test that project selection works in create modal', async ({ page, ctx })
|
|||||||
expect(createBody.data.project_id).not.toBeNull();
|
expect(createBody.data.project_id).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
|
||||||
|
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
|
||||||
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
// The menu that opened the modal animates out and only then hands focus back to its
|
||||||
|
// own trigger.
|
||||||
|
await expect(page.locator('[role="menu"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Fill the description, then move to the project dropdown and select a project purely via keyboard
|
||||||
|
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
|
||||||
|
await description.fill('Keyboard submit test');
|
||||||
|
await description.press('Tab');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
|
||||||
|
).toBeFocused();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByTestId('client_dropdown_search').fill(projectName);
|
||||||
|
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
|
||||||
|
await expect(projectTrigger).toBeVisible();
|
||||||
|
// The trigger label updates on the next tick, but the dropdown keeps focus until its
|
||||||
|
// exit animation has finished and reka-ui hands focus back to the trigger.
|
||||||
|
await expect(projectTrigger).toBeFocused();
|
||||||
|
|
||||||
|
// Ctrl+Enter must submit even though focus is no longer on the description input
|
||||||
|
const [createResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||||
|
),
|
||||||
|
page.keyboard.press('Control+Enter'),
|
||||||
|
]);
|
||||||
|
const createBody = await createResponse.json();
|
||||||
|
expect(createBody.data.description).toBe('Keyboard submit test');
|
||||||
|
expect(createBody.data.project_id).not.toBeNull();
|
||||||
|
await expect(page.getByRole('dialog')).toBeHidden();
|
||||||
|
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
|
||||||
|
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
|
||||||
|
'Keyboard submit test'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that tag selection works in create modal', async ({ page }) => {
|
test('test that tag selection works in create modal', async ({ page }) => {
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
|
|||||||
@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
|
|||||||
test('can navigate forward and return to current week', async ({ page }) => {
|
test('can navigate forward and return to current week', async ({ page }) => {
|
||||||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||||||
|
|
||||||
// Should show "This week"
|
// Should show "This Week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
||||||
|
|
||||||
// Go to next week — the text assertions below auto-retry until the
|
// Go to next week — the text assertions below auto-retry until the
|
||||||
// header label flips.
|
// header label flips.
|
||||||
await page.getByTestId('timesheet_next_week').click();
|
await page.getByTestId('timesheet_next_week').click();
|
||||||
|
|
||||||
// Should no longer show "This week"
|
// Should no longer show "This Week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
|
||||||
|
|
||||||
// Go back to this week
|
// Go back to this week
|
||||||
await page.getByTestId('timesheet_week_display').click();
|
await page.getByTestId('timesheet_week_display').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.6"
|
"ref": "main"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.5"
|
"ref": "v0.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -8564,7 +8564,7 @@
|
|||||||
},
|
},
|
||||||
"resources/js/packages/ui": {
|
"resources/js/packages/ui": {
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.22",
|
"version": "0.0.23",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
|
|||||||
@@ -43,5 +43,7 @@
|
|||||||
<env name="NEWSLETTER_URL" value="null"/>
|
<env name="NEWSLETTER_URL" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
|
||||||
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
|
||||||
|
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
|
||||||
|
<env name="PADDLE_SANDBOX" value="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Client </span>
|
<span> Create Client </span>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Update Client </span>
|
<span> Update Client </span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 Pagination from '@/packages/ui/src/Pagination.vue';
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const roleDescription = computed(() => {
|
|||||||
v-model:show="showOwnershipTransferConfirmModal"
|
v-model:show="showOwnershipTransferConfirmModal"
|
||||||
:member-name="member.name"
|
:member-name="member.name"
|
||||||
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
@submit="submit"></MemberOwnershipTransferConfirmModal>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Update Member </span>
|
<span> Update Member </span>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Invite Member </span>
|
<span> Invite Member </span>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function submitBillableRate() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Edit Project {{ props.originalProject.name }} </span>
|
<span> Edit Project {{ props.originalProject.name }} </span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { computed, ref, watch } 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';
|
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span>Add Project Member</span>
|
<span>Add Project Member</span>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span>Edit Project Member</span>
|
<span>Edit Project Member</span>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Report </span>
|
<span> Create Report </span>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Report </span>
|
<span> Create Report </span>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Update Tag </span>
|
<span> Update Tag </span>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Task </span>
|
<span> Create Task </span>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Update Task </span>
|
<span> Update Task </span>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const max = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const backgroundColor = useCssVariable('--theme-color-card-background');
|
const backgroundColor = useCssVariable('--theme-color-card-background');
|
||||||
const borderColor = useCssVariable('--color-border');
|
const borderColor = useCssVariable('--color-border-secondary');
|
||||||
const labelColor = useCssVariable('--color-text-secondary');
|
const labelColor = useCssVariable('--color-text-secondary');
|
||||||
const chartColorRaw = useCssVariable('--theme-color-chart');
|
const chartColorRaw = useCssVariable('--theme-color-chart');
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="emit('cancel')">
|
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Coffee class="w-5 h-5 text-text-secondary" />
|
<Coffee class="w-5 h-5 text-text-secondary" />
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isCurrentWeek: boolean;
|
|
||||||
weekNumber: number;
|
weekNumber: number;
|
||||||
weekRangeDisplay: string;
|
rangeLabel: string;
|
||||||
weekTotalFormatted: string;
|
weekTotalFormatted: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -19,33 +17,16 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
<!-- Left: Week navigation -->
|
<!-- Left: Week navigation -->
|
||||||
<div class="flex items-center gap-2">
|
<DateRangeNavigator
|
||||||
<Button
|
:label="rangeLabel"
|
||||||
variant="outline"
|
:secondary-label="`W${weekNumber}`"
|
||||||
size="icon"
|
previous-test-id="timesheet_prev_week"
|
||||||
class="h-8 w-8"
|
trigger-test-id="timesheet_week_display"
|
||||||
data-testid="timesheet_prev_week"
|
next-test-id="timesheet_next_week"
|
||||||
@click="$emit('previous')">
|
trigger-aria-label="Return to current week"
|
||||||
<ChevronLeftIcon class="h-4 w-4" />
|
@previous="$emit('previous')"
|
||||||
</Button>
|
@next="$emit('next')"
|
||||||
<button
|
@select="$emit('current')" />
|
||||||
data-testid="timesheet_week_display"
|
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
|
||||||
@click="$emit('current')">
|
|
||||||
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
|
||||||
<span v-if="isCurrentWeek">This week</span>
|
|
||||||
<span v-else>{{ weekRangeDisplay }}</span>
|
|
||||||
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
data-testid="timesheet_next_week"
|
|
||||||
@click="$emit('next')">
|
|
||||||
<ChevronRightIcon class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Week total -->
|
<!-- Right: Week total -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
import Pagination from '@/packages/ui/src/Pagination.vue';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||||
|
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
|
||||||
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
@@ -44,7 +45,6 @@ const {
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekDays,
|
weekDays,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
isCurrentWeek,
|
|
||||||
todayDate,
|
todayDate,
|
||||||
goToPreviousWeek,
|
goToPreviousWeek,
|
||||||
goToNextWeek,
|
goToNextWeek,
|
||||||
@@ -109,13 +109,9 @@ const weekTotalFormatted = computed(() =>
|
|||||||
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekRangeDisplay = computed(() => {
|
const weekRangeLabel = computed(() =>
|
||||||
const start = weekStart.value;
|
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
|
||||||
const end = start.add(6, 'day');
|
);
|
||||||
return start.month() === end.month()
|
|
||||||
? `${start.format('MMM D')} - ${end.format('D')}`
|
|
||||||
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -208,9 +204,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimesheetHeader
|
<TimesheetHeader
|
||||||
:is-current-week="isCurrentWeek"
|
|
||||||
:week-number="weekNumber"
|
:week-number="weekNumber"
|
||||||
:week-range-display="weekRangeDisplay"
|
:range-label="weekRangeLabel"
|
||||||
:week-total-formatted="weekTotalFormatted"
|
:week-total-formatted="weekTotalFormatted"
|
||||||
@previous="goToPreviousWeek"
|
@previous="goToPreviousWeek"
|
||||||
@next="goToNextWeek"
|
@next="goToNextWeek"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.22",
|
"version": "0.0.23",
|
||||||
"description": "Package containing the solidtime ui components",
|
"description": "Package containing the solidtime ui components",
|
||||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||||
"module": "./dist/solidtime-ui-lib.js",
|
"module": "./dist/solidtime-ui-lib.js",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from '../command';
|
} from '../command';
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
|
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||||
import type {
|
import type {
|
||||||
CommandPaletteCommand,
|
CommandPaletteCommand,
|
||||||
CommandPaletteGroup,
|
CommandPaletteGroup,
|
||||||
@@ -36,6 +37,8 @@ const emit = defineEmits<{
|
|||||||
select: [command: CommandPaletteCommand | EntitySearchResult];
|
select: [command: CommandPaletteCommand | EntitySearchResult];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
|
||||||
// Non-empty groups for rendering
|
// Non-empty groups for rendering
|
||||||
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
|
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
|
||||||
|
|
||||||
@@ -71,7 +74,9 @@ watch(open, (isOpen) => {
|
|||||||
)
|
)
|
||||||
">
|
">
|
||||||
<DialogContent
|
<DialogContent
|
||||||
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
|
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||||
|
@open-auto-focus="onOpenAutoFocus"
|
||||||
|
@close-auto-focus="onCloseAutoFocus">
|
||||||
<CommandRoot
|
<CommandRoot
|
||||||
v-model:search-term="searchTerm"
|
v-model:search-term="searchTerm"
|
||||||
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'submit']);
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -24,7 +24,12 @@ const close = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
|
<Modal
|
||||||
|
:show="show"
|
||||||
|
:max-width="maxWidth"
|
||||||
|
:closeable="closeable"
|
||||||
|
@close="close"
|
||||||
|
@submit="emit('submit')">
|
||||||
<div class="px-4 lg:px-6 py-4">
|
<div class="px-4 lg:px-6 py-4">
|
||||||
<div class="text-lg font-medium text-text-primary" role="heading">
|
<div class="text-lg font-medium text-text-primary" role="heading">
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '..';
|
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
|
||||||
import { Tabs, TabsList } from '../tabs';
|
import { Tabs, TabsList } from '../tabs';
|
||||||
import TabBarItem from '../TabBar/TabBarItem.vue';
|
import TabBarItem from '../TabBar/TabBarItem.vue';
|
||||||
|
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
|
||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
viewTitle: string;
|
rangeLabel: string;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
settings: CalendarSettings;
|
settings: CalendarSettings;
|
||||||
}>();
|
}>();
|
||||||
@@ -23,31 +22,13 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<!-- Left: Navigation -->
|
<DateRangeNavigator
|
||||||
<div class="flex items-center gap-1">
|
:label="rangeLabel"
|
||||||
<Button
|
trigger-test-id="calendar-title"
|
||||||
variant="outline"
|
trigger-aria-label="today"
|
||||||
size="sm"
|
@previous="emit('prev')"
|
||||||
class="h-8 w-8 p-0"
|
@next="emit('next')"
|
||||||
aria-label="Previous"
|
@select="emit('today')" />
|
||||||
@click="emit('prev')">
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-8 w-8 p-0"
|
|
||||||
aria-label="Next"
|
|
||||||
@click="emit('next')">
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Title -->
|
|
||||||
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
|
|
||||||
viewTitle
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<!-- Right: View switcher + Settings -->
|
<!-- Right: View switcher + Settings -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
viewDays,
|
viewDays,
|
||||||
viewTitle,
|
rangeLabel,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
:view-title="viewTitle"
|
:range-label="rangeLabel"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@prev="handlePrev"
|
@prev="handlePrev"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
import { getWeekStartDayNumber } from '../utils/settings';
|
import { getWeekStartDayNumber } from '../utils/settings';
|
||||||
|
import { getDateRangeLabel } from '../utils/dateRange';
|
||||||
|
|
||||||
export function useCalendarNavigation(callbacks: {
|
export function useCalendarNavigation(callbacks: {
|
||||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||||
@@ -34,24 +35,10 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
return days;
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewTitle = computed(() => {
|
const rangeLabel = computed(() => {
|
||||||
if (activeView.value === 'timeGridDay') {
|
|
||||||
return currentDate.value.format('MMMM YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = viewDays.value;
|
const days = viewDays.value;
|
||||||
if (days.length === 0) return '';
|
if (days.length === 0) return '';
|
||||||
|
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
|
||||||
const first = days[0]!;
|
|
||||||
const last = days[days.length - 1]!;
|
|
||||||
|
|
||||||
if (first.year() !== last.year()) {
|
|
||||||
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
|
||||||
}
|
|
||||||
if (first.month() !== last.month()) {
|
|
||||||
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
|
||||||
}
|
|
||||||
return first.format('MMMM YYYY');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange() {
|
function emitDatesChange() {
|
||||||
@@ -99,7 +86,7 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
activeView,
|
activeView,
|
||||||
currentDate,
|
currentDate,
|
||||||
viewDays,
|
viewDays,
|
||||||
viewTitle,
|
rangeLabel,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
|
|||||||
81
resources/js/packages/ui/src/Input/DateRangeNavigator.vue
Normal file
81
resources/js/packages/ui/src/Input/DateRangeNavigator.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
|
||||||
|
import { Button } from '../Buttons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
previousDisabled?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
previousTestId?: string;
|
||||||
|
triggerTestId?: string;
|
||||||
|
nextTestId?: string;
|
||||||
|
triggerAriaLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
secondaryLabel: undefined,
|
||||||
|
previousTestId: undefined,
|
||||||
|
triggerTestId: undefined,
|
||||||
|
nextTestId: undefined,
|
||||||
|
triggerAriaLabel: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
previous: [];
|
||||||
|
next: [];
|
||||||
|
select: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
|
||||||
|
role="group"
|
||||||
|
aria-label="Date range navigation">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
||||||
|
aria-label="Previous"
|
||||||
|
:disabled="previousDisabled"
|
||||||
|
:data-testid="previousTestId"
|
||||||
|
@click="emit('previous')">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<slot name="trigger" :trigger-class="triggerClass">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:class="triggerClass"
|
||||||
|
:aria-label="triggerAriaLabel"
|
||||||
|
:data-testid="triggerTestId"
|
||||||
|
@click="emit('select')">
|
||||||
|
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
|
||||||
|
<span class="min-w-0 truncate">{{ label }}</span>
|
||||||
|
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
|
||||||
|
· {{ secondaryLabel }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
||||||
|
aria-label="Next"
|
||||||
|
:disabled="nextDisabled"
|
||||||
|
:data-testid="nextTestId"
|
||||||
|
@click="emit('next')">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
|
|||||||
import { CalendarIcon } from '@lucide/vue';
|
import { CalendarIcon } from '@lucide/vue';
|
||||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import {
|
import DateRangeNavigator from './DateRangeNavigator.vue';
|
||||||
getDayJsInstance,
|
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
|
||||||
getLocalizedDayJs,
|
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
|
||||||
firstDayIndex,
|
|
||||||
type WeekStartDay,
|
|
||||||
} from '@/packages/ui/src/utils/time';
|
|
||||||
import { type Organization } from '@/packages/api/src';
|
import { type Organization } from '@/packages/api/src';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
|
||||||
|
|
||||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
start: string;
|
defineProps<{
|
||||||
end: string;
|
start: string;
|
||||||
}>();
|
end: string;
|
||||||
|
allowFuture?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ allowFuture: false }
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:start', value: string): void;
|
(e: 'update:start', value: string): void;
|
||||||
@@ -35,10 +35,13 @@ interface CalendarDateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = computed(() => {
|
const today = computed(() => {
|
||||||
const now = getDayJsInstance()();
|
const now = getLocalizedDayJs();
|
||||||
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
|
||||||
|
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
|
||||||
|
|
||||||
const modelValue = computed<CalendarDateRange>({
|
const modelValue = computed<CalendarDateRange>({
|
||||||
get: () => ({
|
get: () => ({
|
||||||
start: props.start
|
start: props.start
|
||||||
@@ -91,7 +94,7 @@ function setLastWeek() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast14Days() {
|
function setLast14Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
@@ -109,14 +112,14 @@ function setLastMonth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast30Days() {
|
function setLast30Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLast90Days() {
|
function setLast90Days() {
|
||||||
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
|
||||||
emit('update:end', getDayJsInstance()().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +143,38 @@ function setLastYear() {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
if (!startDay.value) return 'Pick a date';
|
||||||
|
if (!endDay.value) {
|
||||||
|
return getDateRangeLabel(startDay.value, startDay.value, {
|
||||||
|
dateFormat: organization?.value?.date_format,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getDateRangeLabel(startDay.value, endDay.value, {
|
||||||
|
dateFormat: organization?.value?.date_format,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextRange = computed(() => {
|
||||||
|
if (!startDay.value || !endDay.value) return undefined;
|
||||||
|
return shiftDateRange(startDay.value, endDay.value, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextDisabled = computed(
|
||||||
|
() =>
|
||||||
|
!props.allowFuture &&
|
||||||
|
!!nextRange.value &&
|
||||||
|
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
|
||||||
|
);
|
||||||
|
|
||||||
|
function navigate(direction: DateRangeDirection) {
|
||||||
|
if (!startDay.value || !endDay.value) return;
|
||||||
|
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
|
||||||
|
emit('update:start', shifted.start.format());
|
||||||
|
emit('update:end', shifted.end.format());
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
emit('submit');
|
emit('submit');
|
||||||
@@ -148,77 +183,120 @@ watch(open, (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Popover v-model:open="open">
|
<DateRangeNavigator
|
||||||
<PopoverTrigger as-child>
|
:label="displayLabel"
|
||||||
<Button
|
:next-disabled="nextDisabled"
|
||||||
variant="outline"
|
previous-test-id="date_range_picker_previous"
|
||||||
:class="
|
trigger-test-id="date_range_picker_display"
|
||||||
twMerge(
|
next-test-id="date_range_picker_next"
|
||||||
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
@previous="navigate(-1)"
|
||||||
!modelValue && 'text-muted-foreground'
|
@next="navigate(1)">
|
||||||
)
|
<template #trigger="{ triggerClass }">
|
||||||
">
|
<Popover v-model:open="open">
|
||||||
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
<PopoverTrigger as-child>
|
||||||
<template v-if="modelValue.start">
|
<Button
|
||||||
<template v-if="modelValue.end">
|
type="button"
|
||||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
variant="ghost"
|
||||||
-
|
size="sm"
|
||||||
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
|
||||||
</template>
|
data-testid="date_range_picker_display">
|
||||||
<template v-else>
|
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
|
||||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
<span class="min-w-0 truncate">{{ displayLabel }}</span>
|
||||||
</template>
|
</Button>
|
||||||
</template>
|
</PopoverTrigger>
|
||||||
<template v-else> Pick a date </template>
|
<PopoverContent class="w-auto p-0">
|
||||||
</Button>
|
<div class="flex divide-x divide-border-secondary">
|
||||||
</PopoverTrigger>
|
<div
|
||||||
<PopoverContent class="w-auto p-0">
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||||
<div class="flex divide-x divide-border-secondary">
|
<Button
|
||||||
<div
|
variant="ghost"
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
class="justify-start"
|
||||||
>Today</Button
|
@click="setToday"
|
||||||
>
|
>Today</Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
>
|
||||||
>This Week</Button
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
size="sm"
|
||||||
>Last Week</Button
|
class="justify-start"
|
||||||
>
|
@click="setThisWeek"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
>This Week</Button
|
||||||
>Last 14 days</Button
|
>
|
||||||
>
|
<Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
variant="ghost"
|
||||||
>This Month</Button
|
size="sm"
|
||||||
>
|
class="justify-start"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
@click="setLastWeek"
|
||||||
>Last Month</Button
|
>Last Week</Button
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
<Button
|
||||||
>Last 30 days</Button
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
class="justify-start"
|
||||||
>Last 90 days</Button
|
@click="setLast14Days"
|
||||||
>
|
>Last 14 Days</Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
>
|
||||||
>Last 12 months</Button
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
size="sm"
|
||||||
>This year</Button
|
class="justify-start"
|
||||||
>
|
@click="setThisMonth"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
>This Month</Button
|
||||||
>Last year</Button
|
>
|
||||||
>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<div class="pl-2">
|
size="sm"
|
||||||
<RangeCalendar
|
class="justify-start"
|
||||||
v-model="modelValue"
|
@click="setLastMonth"
|
||||||
initial-focus
|
>Last Month</Button
|
||||||
:number-of-months="2"
|
>
|
||||||
:max-value="today"
|
<Button
|
||||||
:week-starts-on="weekStartsOn" />
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
class="justify-start"
|
||||||
</PopoverContent>
|
@click="setLast30Days"
|
||||||
</Popover>
|
>Last 30 Days</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLast90Days"
|
||||||
|
>Last 90 Days</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLast12Months"
|
||||||
|
>Last 12 Months</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setThisYear"
|
||||||
|
>This Year</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLastYear"
|
||||||
|
>Last Year</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<RangeCalendar
|
||||||
|
v-model="modelValue"
|
||||||
|
initial-focus
|
||||||
|
:number-of-months="2"
|
||||||
|
:max-value="allowFuture ? undefined : today"
|
||||||
|
:week-starts-on="weekStartsOn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
</DateRangeNavigator>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
|
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
|
||||||
import { computed } from 'vue';
|
import { computed, nextTick } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'submit']);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (props.closeable) {
|
if (props.closeable) {
|
||||||
@@ -25,6 +25,25 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
|
||||||
|
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
|
||||||
|
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
|
||||||
|
async function onKeydownCapture(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// Inputs like the time and duration fields commit their value on blur, so blur first
|
||||||
|
// and let the resulting model updates settle before submitting.
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active instanceof HTMLElement) {
|
||||||
|
active.blur();
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
return {
|
return {
|
||||||
sm: 'sm:max-w-sm',
|
sm: 'sm:max-w-sm',
|
||||||
@@ -39,7 +58,7 @@ const maxWidthClass = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Dialog :open="show" @update:open="close">
|
<Dialog :open="show" @update:open="close">
|
||||||
<DialogContent :class="maxWidthClass">
|
<DialogContent :class="maxWidthClass">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0" @keydown.capture="onKeydownCapture">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
import { buttonVariants } from './Buttons/index';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from './utils/cn';
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
const page = defineModel<number>('page', { default: 1 });
|
const page = defineModel<number>('page', { default: 1 });
|
||||||
@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Project </span>
|
<span> Create Project </span>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create Tags </span>
|
<span> Create Tags </span>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
|
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
|
||||||
<Coffee class="w-5 h-5" />
|
<Coffee class="w-5 h-5" />
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const billableProxy = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Create manual time entry </span>
|
<span> Create manual time entry </span>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const typeProxy = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Edit time entry </span>
|
<span> Edit time entry </span>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
@@ -60,6 +61,13 @@ const projectId = ref<string | null>(null);
|
|||||||
const billable = ref<boolean | undefined>(undefined);
|
const billable = ref<boolean | undefined>(undefined);
|
||||||
const selectedTags = ref<string[]>([]);
|
const selectedTags = ref<string[]>([]);
|
||||||
|
|
||||||
|
// Clearing the project puts both fields back to "leave unchanged", the same state the form
|
||||||
|
// returns to after a successful submit.
|
||||||
|
function resetProject() {
|
||||||
|
projectId.value = null;
|
||||||
|
taskId.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const timeEntryBillable = computed({
|
const timeEntryBillable = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
if (billable.value === undefined) {
|
if (billable.value === undefined) {
|
||||||
@@ -147,7 +155,7 @@ const showBreakWarning = computed(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
<DialogModal closeable :show="show" @close="show = false" @submit="submit">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<span> Update {{ timeEntries.length }} time entries </span>
|
<span> Update {{ timeEntries.length }} time entries </span>
|
||||||
@@ -182,42 +190,59 @@ const showBreakWarning = computed(
|
|||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="project">Project</FieldLabel>
|
<FieldLabel for="project">Project</FieldLabel>
|
||||||
<TimeTrackerProjectTaskDropdown
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
v-model:project="projectId"
|
<!-- the dropdown declares its own `class` prop, which goes to the
|
||||||
v-model:task="taskId"
|
trigger button, so the growing has to happen on a wrapper -->
|
||||||
variant="input"
|
<div class="flex-1 min-w-0">
|
||||||
align="start"
|
<TimeTrackerProjectTaskDropdown
|
||||||
size="default"
|
v-model:project="projectId"
|
||||||
:clients
|
v-model:task="taskId"
|
||||||
:create-project
|
variant="input"
|
||||||
:create-client
|
align="start"
|
||||||
:currency="currency"
|
size="default"
|
||||||
:organization-billable-rate="organizationBillableRate"
|
:clients
|
||||||
:can-create-project
|
:create-project
|
||||||
empty-placeholder="Select project..."
|
:create-client
|
||||||
allow-reset
|
:currency="currency"
|
||||||
:enable-estimated-time
|
:organization-billable-rate="organizationBillableRate"
|
||||||
:projects="projects"
|
:can-create-project
|
||||||
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
|
empty-placeholder="Select project..."
|
||||||
|
:enable-estimated-time
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="projectId !== null"
|
||||||
|
type="button"
|
||||||
|
data-testid="project_reset_button"
|
||||||
|
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||||
|
@click="resetProject">
|
||||||
|
<XMarkIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Tag</FieldLabel>
|
<FieldLabel>Tag</FieldLabel>
|
||||||
<div class="flex space-x-5">
|
<div class="flex items-center space-x-5">
|
||||||
<TagDropdown
|
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink
|
||||||
v-model="selectedTags"
|
below its trigger and let the button overflow into the checkbox -->
|
||||||
:create-tag
|
<div class="shrink-0">
|
||||||
:tags="tags"
|
<TagDropdown
|
||||||
:show-no-tag-option="false">
|
v-model="selectedTags"
|
||||||
<template #trigger>
|
:create-tag
|
||||||
<Button variant="input" :disabled="removeAllTags">
|
:tags="tags"
|
||||||
<TagIcon class="h-4 text-icon-default" />
|
:show-no-tag-option="false">
|
||||||
<span v-if="selectedTags.length > 0">
|
<template #trigger>
|
||||||
Set {{ selectedTags.length }} tags
|
<Button variant="input" :disabled="removeAllTags">
|
||||||
</span>
|
<TagIcon class="h-4 text-icon-default" />
|
||||||
<span v-else>Select Tags...</span>
|
<span v-if="selectedTags.length > 0">
|
||||||
</Button>
|
Set {{ selectedTags.length }} tags
|
||||||
</template>
|
</span>
|
||||||
</TagDropdown>
|
<span v-else>Select Tags...</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TagDropdown>
|
||||||
|
</div>
|
||||||
<Field orientation="horizontal">
|
<Field orientation="horizontal">
|
||||||
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
|
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
|
||||||
<FieldLabel for="no_tags">Remove all tags</FieldLabel>
|
<FieldLabel for="no_tags">Remove all tags</FieldLabel>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
Client,
|
Client,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
|
|
||||||
import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
|
import { PlusCircleIcon, MinusIcon } from '@heroicons/vue/16/solid';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
@@ -58,7 +58,6 @@ const props = withDefaults(
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
currency: string;
|
currency: string;
|
||||||
emptyPlaceholder?: string;
|
emptyPlaceholder?: string;
|
||||||
allowReset?: boolean;
|
|
||||||
noProjectValue?: string | null;
|
noProjectValue?: string | null;
|
||||||
enableEstimatedTime: boolean;
|
enableEstimatedTime: boolean;
|
||||||
organizationBillableRate: number | null;
|
organizationBillableRate: number | null;
|
||||||
@@ -70,7 +69,6 @@ const props = withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
emptyPlaceholder: 'No Project',
|
emptyPlaceholder: 'No Project',
|
||||||
allowReset: false,
|
|
||||||
noProjectValue: NO_PROJECT_ID,
|
noProjectValue: NO_PROJECT_ID,
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -558,12 +556,6 @@ function selectProject(projectId: string) {
|
|||||||
emit('changed', project.value, task.value);
|
emit('changed', project.value, task.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetProject() {
|
|
||||||
project.value = null;
|
|
||||||
task.value = null;
|
|
||||||
emit('changed', project.value, task.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCreateProject = ref(false);
|
const showCreateProject = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -571,40 +563,28 @@ const showCreateProject = ref(false);
|
|||||||
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
|
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<slot name="trigger">
|
<slot name="trigger">
|
||||||
<div class="flex items-center gap-1">
|
<Button
|
||||||
<Button
|
:variant="props.variant"
|
||||||
:variant="props.variant"
|
:size="props.size"
|
||||||
:size="props.size"
|
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
<div
|
||||||
<div
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
<span class="truncate shrink-[1] text-text-primary">{{
|
||||||
<span class="truncate shrink-[1] text-text-primary">{{
|
selectedProjectName
|
||||||
selectedProjectName
|
}}</span>
|
||||||
}}</span>
|
<template v-if="currentTask">
|
||||||
<template v-if="currentTask">
|
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
|
||||||
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
|
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
</template>
|
||||||
</template>
|
<template v-if="selectedClientName">
|
||||||
<template v-if="selectedClientName">
|
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span>
|
||||||
<span class="dark:text-text-tertiary text-text-quaternary shrink-0"
|
<span
|
||||||
>•</span
|
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
|
||||||
>
|
>{{ selectedClientName }}</span
|
||||||
<span
|
>
|
||||||
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
|
</template>
|
||||||
>{{ selectedClientName }}</span
|
</Button>
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<button
|
|
||||||
v-if="allowReset && project !== null"
|
|
||||||
type="button"
|
|
||||||
data-testid="project_reset_button"
|
|
||||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
|
||||||
@click.stop="resetProject">
|
|
||||||
<XMarkIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
|
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||||
import {
|
import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
type DialogContentEmits,
|
type DialogContentEmits,
|
||||||
@@ -20,6 +21,10 @@ const delegatedProps = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
|
||||||
|
// Forwarded consumer listeners run first, so a consumer can still take over
|
||||||
|
// by calling preventDefault() on close-auto-focus.
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,7 +41,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
">
|
"
|
||||||
|
@open-auto-focus="onOpenAutoFocus"
|
||||||
|
@close-auto-focus="onCloseAutoFocus">
|
||||||
<slot />
|
<slot />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
|
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
|
||||||
import { X } from '@lucide/vue';
|
import { X } from '@lucide/vue';
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -22,6 +23,10 @@ const delegatedProps = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
|
||||||
|
// Forwarded consumer listeners run first, so a consumer can still take over
|
||||||
|
// by calling preventDefault() on close-auto-focus.
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
|
@open-auto-focus="onOpenAutoFocus"
|
||||||
|
@close-auto-focus="onCloseAutoFocus"
|
||||||
@pointer-down-outside="
|
@pointer-down-outside="
|
||||||
(event) => {
|
(event) => {
|
||||||
const originalEvent = event.detail.originalEvent;
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import InputLabel from './Input/InputLabel.vue';
|
|||||||
import TextInput from './Input/TextInput.vue';
|
import TextInput from './Input/TextInput.vue';
|
||||||
import LoadingSpinner from './LoadingSpinner.vue';
|
import LoadingSpinner from './LoadingSpinner.vue';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
|
import Pagination from './Pagination.vue';
|
||||||
import ProjectBadge from './Project/ProjectBadge.vue';
|
import ProjectBadge from './Project/ProjectBadge.vue';
|
||||||
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
|
||||||
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||||
@@ -131,6 +132,7 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||||
|
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
|
||||||
import { Label } from './label/index';
|
import { Label } from './label/index';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -210,6 +212,7 @@ export {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
|
DateRangeNavigator,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -257,6 +260,7 @@ export {
|
|||||||
NumberFieldDecrement,
|
NumberFieldDecrement,
|
||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
|
|||||||
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal file
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from './time';
|
||||||
|
import { getDateRangeLabel, shiftDateRange } from './dateRange';
|
||||||
|
|
||||||
|
const reference = dayjs('2026-09-21');
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDateRangeLabel', () => {
|
||||||
|
test('labels relative weeks', () => {
|
||||||
|
getDayJsInstance();
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Week');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(
|
||||||
|
reference.subtract(1, 'week').startOf('week'),
|
||||||
|
reference.subtract(1, 'week').endOf('week'),
|
||||||
|
{ referenceDate: reference }
|
||||||
|
)
|
||||||
|
).toBe('Last Week');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels common rolling ranges', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('Last 14 Days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels full calendar months and years', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('September 2025');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps relative labels for the current periods', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Month');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Year');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the organization date format for custom ranges', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
|
||||||
|
dateFormat: 'slash-separated-dd-mm-yyyy',
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('03/08/2026 – 12/08/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a single historical day without duplicating it', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('Aug 3, 2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shiftDateRange', () => {
|
||||||
|
test('moves full weeks as calendar periods', () => {
|
||||||
|
getDayJsInstance();
|
||||||
|
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves full months without truncating longer months', () => {
|
||||||
|
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves custom ranges by their inclusive duration', () => {
|
||||||
|
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes custom range offsets across the spring DST transition', () => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
||||||
|
const shifted = shiftDateRange(
|
||||||
|
getLocalizedDayJs('2026-03-14T23:00:00Z'),
|
||||||
|
getLocalizedDayJs('2026-03-27T23:00:00Z'),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
|
||||||
|
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes custom range offsets across the autumn DST transition', () => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
||||||
|
const shifted = shiftDateRange(
|
||||||
|
getLocalizedDayJs('2026-10-17T22:00:00Z'),
|
||||||
|
getLocalizedDayJs('2026-10-30T23:00:00Z'),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
|
||||||
|
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal file
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
|
||||||
|
|
||||||
|
export type DateRangeDirection = -1 | 1;
|
||||||
|
|
||||||
|
function isSameDay(left: Dayjs, right: Dayjs): boolean {
|
||||||
|
return left.isSame(right, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
|
||||||
|
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactRange(start: Dayjs, end: Dayjs): string {
|
||||||
|
if (start.year() !== end.year()) {
|
||||||
|
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
|
||||||
|
}
|
||||||
|
if (start.month() !== end.month()) {
|
||||||
|
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
|
||||||
|
}
|
||||||
|
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-friendly label for an inclusive date range. Relative labels
|
||||||
|
* are intentionally shared by reporting, the timesheet, and the calendar.
|
||||||
|
*/
|
||||||
|
export function getDateRangeLabel(
|
||||||
|
start: Dayjs,
|
||||||
|
end: Dayjs,
|
||||||
|
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
|
||||||
|
): string {
|
||||||
|
// Ensure the configured first day of the week is applied before using startOf('week').
|
||||||
|
getDayJsInstance();
|
||||||
|
|
||||||
|
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
|
||||||
|
const rangeStart = start.startOf('day');
|
||||||
|
const rangeEnd = end.startOf('day');
|
||||||
|
|
||||||
|
const relativeDays: Array<[number, string]> = [
|
||||||
|
[0, 'Today'],
|
||||||
|
[-1, 'Yesterday'],
|
||||||
|
[1, 'Tomorrow'],
|
||||||
|
];
|
||||||
|
for (const [offset, label] of relativeDays) {
|
||||||
|
const day = reference.add(offset, 'day');
|
||||||
|
if (isRange(rangeStart, rangeEnd, day, day)) return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
|
||||||
|
['week', 0, 'This Week'],
|
||||||
|
['week', -1, 'Last Week'],
|
||||||
|
['week', 1, 'Next Week'],
|
||||||
|
['month', 0, 'This Month'],
|
||||||
|
['month', -1, 'Last Month'],
|
||||||
|
['month', 1, 'Next Month'],
|
||||||
|
['year', 0, 'This Year'],
|
||||||
|
['year', -1, 'Last Year'],
|
||||||
|
['year', 1, 'Next Year'],
|
||||||
|
];
|
||||||
|
for (const [unit, offset, label] of relativePeriods) {
|
||||||
|
const period = reference.add(offset, unit);
|
||||||
|
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named calendar periods outside the relative windows above, e.g. "May 2025"
|
||||||
|
// for a full month and "2025" for a full year.
|
||||||
|
const monthStart = rangeStart.startOf('month');
|
||||||
|
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
|
||||||
|
return rangeStart.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
const yearStart = rangeStart.startOf('year');
|
||||||
|
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
|
||||||
|
return rangeStart.format('YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollingRanges: Array<[number, string]> = [
|
||||||
|
[14, 'Last 14 Days'],
|
||||||
|
[30, 'Last 30 Days'],
|
||||||
|
[90, 'Last 90 Days'],
|
||||||
|
];
|
||||||
|
for (const [days, label] of rollingRanges) {
|
||||||
|
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
|
||||||
|
return 'Last 12 Months';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameDay(rangeStart, rangeEnd)) {
|
||||||
|
return options.dateFormat
|
||||||
|
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
|
||||||
|
: rangeStart.format('MMM D, YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dateFormat) {
|
||||||
|
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
|
||||||
|
rangeEnd.format('YYYY-MM-DD'),
|
||||||
|
options.dateFormat
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
return formatCompactRange(rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift an inclusive range by one period, preserving full calendar periods. */
|
||||||
|
export function shiftDateRange(
|
||||||
|
start: Dayjs,
|
||||||
|
end: Dayjs,
|
||||||
|
direction: DateRangeDirection
|
||||||
|
): { start: Dayjs; end: Dayjs } {
|
||||||
|
getDayJsInstance();
|
||||||
|
|
||||||
|
const rangeStart = start.startOf('day');
|
||||||
|
const rangeEnd = end.endOf('day');
|
||||||
|
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('year') };
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('month') };
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('week') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
|
||||||
|
return {
|
||||||
|
start: rangeStart.add(days * direction, 'day').startOf('day'),
|
||||||
|
end: rangeEnd.add(days * direction, 'day').endOf('day'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useDialogFocusRestore } from './useDialogFocusRestore';
|
||||||
|
|
||||||
|
function closeEvent() {
|
||||||
|
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useDialogFocusRestore', () => {
|
||||||
|
it('restores focus to the element focused when the dialog opened', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.blur();
|
||||||
|
|
||||||
|
const event = closeEvent();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
|
||||||
|
button.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses nothing when the dialog was opened with nothing focused', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const stale = document.createElement('button');
|
||||||
|
document.body.appendChild(stale);
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
|
||||||
|
// First open from the button, then close
|
||||||
|
stale.focus();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
onCloseAutoFocus(closeEvent());
|
||||||
|
vi.runAllTimers();
|
||||||
|
stale.blur();
|
||||||
|
|
||||||
|
// Second open from the body must not refocus the stale button
|
||||||
|
onOpenAutoFocus();
|
||||||
|
const event = closeEvent();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
stale.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore focus to an element that was removed', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.remove();
|
||||||
|
|
||||||
|
onCloseAutoFocus(closeEvent());
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves control to a consumer that already prevented the event', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.blur();
|
||||||
|
|
||||||
|
const event = closeEvent();
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
button.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Restores focus to the element that was focused when a dialog opened.
|
||||||
|
*
|
||||||
|
* reka-ui remembers the active element at content mount as the dialog's
|
||||||
|
* "trigger" (only when it is not the body) and refocuses it on every close,
|
||||||
|
* but it never clears that value. A dialog opened while nothing is focused
|
||||||
|
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
|
||||||
|
* whatever triggered a *previous* open. Bind these handlers to
|
||||||
|
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
|
||||||
|
* exactly the previously focused element, or nothing.
|
||||||
|
*
|
||||||
|
* A consumer handler that already called `preventDefault()` on
|
||||||
|
* `close-auto-focus` keeps control; this composable then does nothing.
|
||||||
|
*/
|
||||||
|
export function useDialogFocusRestore() {
|
||||||
|
let previouslyFocused: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function onOpenAutoFocus() {
|
||||||
|
const active = document.activeElement;
|
||||||
|
previouslyFocused =
|
||||||
|
active instanceof HTMLElement && active !== document.body ? active : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseAutoFocus(event: Event) {
|
||||||
|
const target = previouslyFocused;
|
||||||
|
previouslyFocused = null;
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
|
||||||
|
event.preventDefault();
|
||||||
|
// Same tick reka-ui uses, so the dialog content is fully gone first
|
||||||
|
setTimeout(() => {
|
||||||
|
if (target?.isConnected) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onOpenAutoFocus, onCloseAutoFocus };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
@@ -57,7 +57,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||||
deep: true,
|
deep: true,
|
||||||
});
|
});
|
||||||
@@ -89,23 +88,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
try {
|
try {
|
||||||
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
||||||
if (timeEntriesResponse?.data) {
|
if (timeEntriesResponse?.data) {
|
||||||
if (timeEntriesResponse.data) {
|
currentTimeEntry.value = timeEntriesResponse.data;
|
||||||
currentTimeEntry.value = timeEntriesResponse.data;
|
} else if (currentTimeEntry.value.id !== '') {
|
||||||
if (
|
// No active time entry on server
|
||||||
currentTimeEntry.value.start !== '' &&
|
// Only reset if we had a previously started timer (has an ID)
|
||||||
currentTimeEntry.value.end === null
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||||
) {
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||||
startLiveTimer();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No active time entry on server
|
|
||||||
// Only reset if we had a previously started timer (has an ID)
|
|
||||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
|
||||||
if (currentTimeEntry.value.id !== '') {
|
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
|
||||||
stopLiveTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// API error (e.g., 404 when no active time entry)
|
// API error (e.g., 404 when no active time entry)
|
||||||
@@ -113,7 +101,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||||
if (currentTimeEntry.value.id !== '') {
|
if (currentTimeEntry.value.id !== '') {
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||||
stopLiveTimer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -294,6 +281,18 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
return isActive.value && currentTimeEntry.value.type === 'break';
|
return isActive.value && currentTimeEntry.value.type === 'break';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isActive,
|
||||||
|
(active) => {
|
||||||
|
if (active) {
|
||||||
|
startLiveTimer();
|
||||||
|
} else {
|
||||||
|
stopLiveTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function setActiveState(newState: boolean) {
|
async function setActiveState(newState: boolean) {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
|
|||||||
4
resources/testfiles/clockify_projects_import_test_5.csv
Normal file
4
resources/testfiles/clockify_projects_import_test_5.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"Project","Client","Status","Visibility","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Project members","Project manager","Note"
|
||||||
|
"Project for Big Company","Big Company","Active","Public","Task 1, Task 2, Task 3","1.38","","","","","Constantin Graf","",""
|
||||||
|
"Project without Client","","Active","Public","","0.00","","","","","Constantin Graf","",""
|
||||||
|
"TEST","Big Company","Active","Public","","0.00","","","","","Constantin Graf","",""
|
||||||
|
@@ -6,6 +6,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PasswordConfirmationTest extends TestCase
|
class PasswordConfirmationTest extends TestCase
|
||||||
@@ -43,4 +44,43 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
|
|
||||||
$response->assertSessionHasErrors();
|
$response->assertSessionHasErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_can_be_confirmed_if_a_placeholder_user_with_the_same_email_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Placeholders created by an import have no password at all. The placeholder is created
|
||||||
|
// first so that it would be returned by an unordered lookup by email.
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email, 'password' => null]);
|
||||||
|
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('secret-password')]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||||
|
'password' => 'secret-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($this->app['session']->has('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_confirmation_ignores_the_password_of_a_placeholder_user_with_the_same_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Placeholders created by removing a member copy the password hash as of the removal,
|
||||||
|
// so the placeholder holds a password that the real user has since replaced.
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email, 'password' => Hash::make('outdated-password')]);
|
||||||
|
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('current-password')]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||||
|
'password' => 'outdated-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSessionHasErrors();
|
||||||
|
$this->assertFalse($this->app['session']->has('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -93,4 +94,62 @@ class PasswordResetTest extends TestCase
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_targets_the_real_user_when_a_placeholder_user_with_the_same_email_exists(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
// The placeholder is created first so that it would be returned by an unordered lookup by email
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$user = User::factory()->create(['email' => $email]);
|
||||||
|
$placeholderPasswordBefore = $placeholder->password;
|
||||||
|
|
||||||
|
$response = $this->post('/forgot-password', [
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
Notification::assertNotSentTo($placeholder, ResetPassword::class);
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($email) {
|
||||||
|
$response = $this->post('/reset-password', [
|
||||||
|
'token' => $notification->token,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => 'new-password-123',
|
||||||
|
'password_confirmation' => 'new-password-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$placeholder->refresh();
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame($placeholderPasswordBefore, $placeholder->password);
|
||||||
|
$this->assertTrue(Hash::check('new-password-123', $user->password));
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'email' => $email,
|
||||||
|
'password' => 'new-password-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_link_is_not_sent_if_only_a_placeholder_user_with_the_email_exists(): void
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$placeholder = User::factory()->placeholder()->create();
|
||||||
|
|
||||||
|
$response = $this->post('/forgot-password', [
|
||||||
|
'email' => $placeholder->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('email');
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use App\Auth\ActiveUserProvider;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
|
#[CoversClass(ActiveUserProvider::class)]
|
||||||
|
class ActiveUserProviderTest extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
public function test_password_broker_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$brokerProvider = Auth::createUserProvider(config('auth.passwords.users.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $brokerProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_guard_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$guardProvider = Auth::createUserProvider(config('auth.guards.api.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_web_guard_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$guardProvider = Auth::createUserProvider(config('auth.guards.web.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_credentials_ignores_placeholder_users_with_the_same_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$user = User::factory()->create(['email' => $email]);
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $provider->retrieveByCredentials(['email' => $email]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(User::class, $result);
|
||||||
|
$this->assertTrue($user->is($result));
|
||||||
|
$this->assertFalse($placeholder->is($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_credentials_returns_null_if_only_a_placeholder_user_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$email = 'placeholder-only@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $provider->retrieveByCredentials(['email' => $email]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_id_returns_null_for_placeholder_users(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$placeholder = User::factory()->placeholder()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$placeholderResult = $provider->retrieveById($placeholder->getKey());
|
||||||
|
$userResult = $provider->retrieveById($user->getKey());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNull($placeholderResult);
|
||||||
|
$this->assertInstanceOf(User::class, $userResult);
|
||||||
|
$this->assertTrue($user->is($userResult));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,29 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_fails_if_data_exceeds_maximum_size(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.max_data_size' => 16]);
|
||||||
|
$user = $this->createUserWithPermission([
|
||||||
|
'import',
|
||||||
|
]);
|
||||||
|
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldNotReceive('import');
|
||||||
|
});
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [
|
||||||
|
'type' => 'toggl_time_entries',
|
||||||
|
'data' => base64_encode(str_repeat('a', 15)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['data']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_return_error_message_if_import_fails(): void
|
public function test_import_return_error_message_if_import_fails(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
|||||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||||
'id' => $invitation->getKey(),
|
'id' => $invitation->getKey(),
|
||||||
]);
|
]);
|
||||||
|
// Joining sets the organization as the current one for the user, independently of the
|
||||||
|
// placeholders that were merged into them
|
||||||
|
$this->assertSame($user->organization->getKey(), $user2->user->fresh()->current_team_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ class UserModelTest extends ModelTestAbstract
|
|||||||
$this->assertTrue($canAccess);
|
$this->assertTrue($canAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_placeholder_user_with_a_super_admin_email_can_not_access_admin_panel(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);
|
||||||
|
$user = User::factory()->placeholder()->create([
|
||||||
|
'email' => 'some@email.test',
|
||||||
|
]);
|
||||||
|
$panelProvider = new AdminPanelProvider(app());
|
||||||
|
$mainPanel = $panelProvider->panel(Panel::make());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$canAccess = $user->canAccessPanel($mainPanel);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($canAccess);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -412,10 +412,11 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertDatabaseHas(Organization::class, [
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
'id' => $organizationOfA->getKey(),
|
'id' => $organizationOfA->getKey(),
|
||||||
]);
|
]);
|
||||||
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
|
// The placeholder user should exist and must not reference the deleted organization,
|
||||||
|
// which is what caused the foreign key violation in #989
|
||||||
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
||||||
$this->assertNotNull($placeholderUser);
|
$this->assertNotNull($placeholderUser);
|
||||||
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
|
$this->assertNull($placeholderUser->current_team_id);
|
||||||
$this->assertDatabaseHas(Member::class, [
|
$this->assertDatabaseHas(Member::class, [
|
||||||
'id' => $memberBInOrgA->getKey(),
|
'id' => $memberBInOrgA->getKey(),
|
||||||
'user_id' => $placeholderUser->getKey(),
|
'user_id' => $placeholderUser->getKey(),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ImportServiceTest extends TestCase
|
|||||||
$this->assertSame(1, $report->usersCreated);
|
$this->assertSame(1, $report->usersCreated);
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
$this->assertSame(2, $report->projectsCreated);
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
$this->assertSame(1, $report->clientsCreated);
|
||||||
|
Storage::disk(config('filesystems.default'))->assertDirectoryEmpty('import');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_of_test_file_without_billability_column_defaults_to_not_billable(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new ClockifyProjectsImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
// Some Clockify exports don't contain a "Billability" column.
|
||||||
|
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_5.csv');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project for Big Company')->firstOrFail();
|
||||||
|
$this->assertFalse($project->is_billable);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_supports_activities_column_alias_for_tasks(): void
|
public function test_import_supports_activities_column_alias_for_tasks(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -66,4 +66,27 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->projectsCreated);
|
$this->assertSame(0, $report->projectsCreated);
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
$this->assertSame(0, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_fails_if_task_name_is_too_long(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new GenericTimeEntriesImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$taskName = str_repeat('a', 501);
|
||||||
|
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
|
||||||
|
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","'.$taskName.'","Peter Tester","peter.test@email.test"';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('Task name ("'.$taskName.'") is too long, maximum length is 500 characters', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_throws_exception_if_zip_exceeds_uncompressed_size_limit(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 10]);
|
||||||
|
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new SolidtimeImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ImportException::class, $e);
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 10 bytes', $e->getMessage());
|
||||||
|
$this->assertSame(0, $importer->getReport()->timeEntriesCreated);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_throws_exception_if_zip_contains_too_many_files(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_files' => 1]);
|
||||||
|
$zipPath = $this->createTestZip('toggl_data_import_test_1');
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new TogglDataImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ImportException::class, $e);
|
||||||
|
$this->assertSame('ZIP contains too many files, maximum is 1', $e->getMessage());
|
||||||
|
$this->assertSame(0, $importer->getReport()->projectsCreated);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
|
use App\Service\Import\Importers\ZipImportHelper;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
use Tests\TestCase;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
#[CoversClass(ZipImportHelper::class)]
|
||||||
|
class ZipImportHelperTest extends TestCase
|
||||||
|
{
|
||||||
|
private TemporaryDirectory $sourceDirectory;
|
||||||
|
|
||||||
|
private TemporaryDirectory $targetDirectory;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->sourceDirectory = TemporaryDirectory::make();
|
||||||
|
$this->targetDirectory = TemporaryDirectory::make();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->sourceDirectory->delete();
|
||||||
|
$this->targetDirectory->delete();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $files
|
||||||
|
*/
|
||||||
|
private function createZip(array $files): string
|
||||||
|
{
|
||||||
|
$zipPath = $this->sourceDirectory->path('test.zip');
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach ($files as $name => $content) {
|
||||||
|
$zip->addFromString($name, $content);
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNothingExtracted(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], array_values(array_diff(scandir($this->targetDirectory->path()), ['.', '..'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_extracts_files_and_nested_directories(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'meta.json' => '{"version":"1.0"}',
|
||||||
|
'nested/dir/file.csv' => 'a,b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('{"version":"1.0"}', file_get_contents($this->targetDirectory->path('meta.json')));
|
||||||
|
$this->assertSame('a,b', file_get_contents($this->targetDirectory->path('nested/dir/file.csv')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_file_is_not_a_zip(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$path = $this->sourceDirectory->path('not-a-zip.txt');
|
||||||
|
file_put_contents($path, 'not a zip');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($path, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_too_many_files(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_files' => 2]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'a.txt' => 'a',
|
||||||
|
'b.txt' => 'b',
|
||||||
|
'c.txt' => 'c',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains too many files, maximum is 2', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_before_writing_if_declared_uncompressed_size_exceeds_limit(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 100]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'a.txt' => str_repeat('a', 60),
|
||||||
|
'b.txt' => str_repeat('b', 60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 100 bytes', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_actual_uncompressed_size_exceeds_limit_despite_forged_headers(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 1000]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'bomb.bin' => str_repeat("\0", 100000),
|
||||||
|
]);
|
||||||
|
// Forge the uncompressed size in the local file header (offset 22) and central directory header (offset 24)
|
||||||
|
$content = file_get_contents($zipPath);
|
||||||
|
$forgedSize = pack('V', 10);
|
||||||
|
$localHeaderOffset = strpos($content, "PK\x03\x04");
|
||||||
|
$centralHeaderOffset = strpos($content, "PK\x01\x02");
|
||||||
|
$this->assertNotFalse($localHeaderOffset);
|
||||||
|
$this->assertNotFalse($centralHeaderOffset);
|
||||||
|
$content = substr_replace($content, $forgedSize, $localHeaderOffset + 22, 4);
|
||||||
|
$content = substr_replace($content, $forgedSize, $centralHeaderOffset + 24, 4);
|
||||||
|
file_put_contents($zipPath, $content);
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$this->assertTrue($zip->open($zipPath, ZipArchive::RDONLY));
|
||||||
|
$this->assertSame(10, $zip->statIndex(0)['size']);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 1000 bytes', $e->getMessage());
|
||||||
|
$extracted = $this->targetDirectory->path('bomb.bin');
|
||||||
|
if (file_exists($extracted)) {
|
||||||
|
$this->assertLessThanOrEqual(1000, filesize($extracted));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_path_traversal(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'../evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "../evil.txt"', $e->getMessage());
|
||||||
|
$this->assertFileDoesNotExist(dirname($this->targetDirectory->path()).'/evil.txt');
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_nested_path_traversal(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'sub/../../evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "sub/../../evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_absolute_path(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'/tmp/evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "/tmp/evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_backslash_path(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'..\\evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "..\\evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user