mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-16 01:33:18 +01:00
Compare commits
1 Commits
project-ta
...
cf83f38321
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf83f38321 |
19
.github/workflows/build-onpremise.yml
vendored
19
.github/workflows/build-onpremise.yml
vendored
@@ -103,6 +103,8 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||||
|
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
|
||||||
|
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
@@ -119,9 +121,26 @@ jobs:
|
|||||||
- name: "Install npm dependencies in invoicing extension"
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
run: cd extensions/Invoicing && npm ci
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout auditing extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
|
||||||
|
path: extensions/Auditing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in auditing extension"
|
||||||
|
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in auditing extension"
|
||||||
|
run: cd extensions/Auditing && npm ci
|
||||||
|
|
||||||
- name: "Activate invoicing extension"
|
- name: "Activate invoicing extension"
|
||||||
run: php artisan module:enable Invoicing
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Activate auditing extension"
|
||||||
|
run: php artisan module:enable Auditing
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
- name: "Install npm dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
24
.github/workflows/build-private.yml
vendored
24
.github/workflows/build-private.yml
vendored
@@ -84,6 +84,8 @@ jobs:
|
|||||||
echo "services_ref=$(jq -r '.Services.ref' 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_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
|
||||||
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
|
||||||
|
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
|
||||||
|
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
@@ -145,6 +147,25 @@ jobs:
|
|||||||
- name: "Install npm dependencies in invoicing extension"
|
- name: "Install npm dependencies in invoicing extension"
|
||||||
run: cd extensions/Invoicing && npm ci
|
run: cd extensions/Invoicing && npm ci
|
||||||
|
|
||||||
|
- name: "Checkout auditing extension"
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
|
||||||
|
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
|
||||||
|
path: extensions/Auditing
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
|
||||||
|
|
||||||
|
- name: "Install composer dependencies in auditing extension"
|
||||||
|
uses: php-actions/composer@v6
|
||||||
|
with:
|
||||||
|
working_dir: "extensions/Auditing"
|
||||||
|
command: install
|
||||||
|
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
php_version: 8.3
|
||||||
|
|
||||||
|
- name: "Install npm dependencies in auditing extension"
|
||||||
|
run: cd extensions/Auditing && npm ci
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
- name: "Setup PHP with PECL extension"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -168,6 +189,9 @@ jobs:
|
|||||||
- name: "Activate invoicing extension"
|
- name: "Activate invoicing extension"
|
||||||
run: php artisan module:enable Invoicing
|
run: php artisan module:enable Invoicing
|
||||||
|
|
||||||
|
- name: "Activate auditing extension"
|
||||||
|
run: php artisan module:enable Auditing
|
||||||
|
|
||||||
- name: "Install npm dependencies"
|
- name: "Install npm dependencies"
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AuditResource\Pages;
|
|
||||||
use App\Models\Audit;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
|
||||||
|
|
||||||
class AuditResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Audit::class;
|
|
||||||
|
|
||||||
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
|
|
||||||
|
|
||||||
protected static ?string $navigationGroup = 'System';
|
|
||||||
|
|
||||||
public static function form(Form $form): Form
|
|
||||||
{
|
|
||||||
return $form
|
|
||||||
->schema([
|
|
||||||
Forms\Components\TextInput::make('user_type')
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('user_id'),
|
|
||||||
Forms\Components\TextInput::make('event')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('auditable_type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
Forms\Components\TextInput::make('auditable_id')
|
|
||||||
->required(),
|
|
||||||
PrettyJsonField::make('old_values'),
|
|
||||||
PrettyJsonField::make('new_values'),
|
|
||||||
Forms\Components\Textarea::make('url'),
|
|
||||||
Forms\Components\TextInput::make('ip_address'),
|
|
||||||
Forms\Components\TextInput::make('user_agent')
|
|
||||||
->maxLength(1023),
|
|
||||||
Forms\Components\TextInput::make('tags')
|
|
||||||
->maxLength(255),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('user.name'),
|
|
||||||
Tables\Columns\TextColumn::make('event'),
|
|
||||||
Tables\Columns\TextColumn::make('auditable_type'),
|
|
||||||
Tables\Columns\TextColumn::make('auditable_id'),
|
|
||||||
IconColumn::make('was_command')
|
|
||||||
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->sortable()
|
|
||||||
->dateTime(),
|
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
|
||||||
->sortable()
|
|
||||||
->dateTime(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
//
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Tables\Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
])
|
|
||||||
->defaultSort('created_at', 'desc');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getRelations(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListAudits::route('/'),
|
|
||||||
'create' => Pages\CreateAudit::route('/create'),
|
|
||||||
'view' => Pages\ViewAudit::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\AuditResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AuditResource;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateAudit extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = AuditResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\AuditResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AuditResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListAudits extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = AuditResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\AuditResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AuditResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewAudit extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = AuditResource::class;
|
|
||||||
}
|
|
||||||
@@ -53,47 +53,6 @@ function todayAt(hour: number, minute: number = 0): string {
|
|||||||
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
|
|
||||||
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
|
|
||||||
// whichever day the suite runs on.
|
|
||||||
function wednesdayAt(hour: number, minute: number = 0): Date {
|
|
||||||
const now = new Date();
|
|
||||||
const daysSinceMonday = (now.getDay() + 6) % 7;
|
|
||||||
return new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate() - daysSinceMonday + 2,
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function thursdayAt(hour: number, minute: number = 0): Date {
|
|
||||||
const d = wednesdayAt(hour, minute);
|
|
||||||
d.setDate(d.getDate() + 1);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toApiTimestamp(date: Date): string {
|
|
||||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
|
|
||||||
function toDateStr(date: Date): string {
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
|
|
||||||
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
|
|
||||||
await expect(header).toBeVisible();
|
|
||||||
const box = await header.boundingBox();
|
|
||||||
return box!.x + box!.width / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests verify that changing the project on a time entry via the calendar
|
* These tests verify that changing the project on a time entry via the calendar
|
||||||
* updates the billable status to match the new project's is_billable setting.
|
* updates the billable status to match the new project's is_billable setting.
|
||||||
@@ -1003,9 +962,14 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
|
const now = new Date();
|
||||||
const start = toApiTimestamp(thursdayAt(0, 30));
|
const dayOfWeek = now.getDay();
|
||||||
const end = toApiTimestamp(thursdayAt(1, 30));
|
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
|
||||||
|
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
|
||||||
|
|
||||||
|
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
|
||||||
|
const start = todayAt(0, 30);
|
||||||
|
const end = todayAt(1, 30);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Drag up past midnight test',
|
description: 'Drag up past midnight test',
|
||||||
start,
|
start,
|
||||||
@@ -1014,10 +978,11 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
.toISOString()
|
||||||
);
|
.split('T')[0];
|
||||||
const event = thursdayCol
|
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||||
|
const event = todayCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Drag up past midnight test' });
|
.filter({ hasText: 'Drag up past midnight test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
@@ -1053,7 +1018,10 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
expect(newDurationMs).toBe(3600000);
|
expect(newDurationMs).toBe(3600000);
|
||||||
|
|
||||||
// The event should have moved to the previous day
|
// The event should have moved to the previous day
|
||||||
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
|
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1293,8 +1261,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(wednesdayAt(11));
|
const end = todayAt(11);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1303,8 +1271,26 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Resize into Thursday's column (for end resize, end must be > start)
|
// Find a column AFTER today (for end resize, end must be > start)
|
||||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let todayIndex = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
todayIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pick first column after today, or skip if today is last
|
||||||
|
for (let i = todayIndex + 1; i < headerCount; i++) {
|
||||||
|
const box = await headers.nth(i).boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
test.skip(targetX === undefined, 'No column after today to resize to');
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1332,7 +1318,7 @@ test.describe('Resize Events', () => {
|
|||||||
// First drag down vertically to engage resize (like test 3.1)
|
// First drag down vertically to engage resize (like test 3.1)
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||||||
// Then move horizontally to a later day column
|
// Then move horizontally to a later day column
|
||||||
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
|
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1354,8 +1340,8 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(wednesdayAt(14));
|
const end = todayAt(14);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1364,8 +1350,30 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Resize into Thursday's column (later day needed for end-edge resize)
|
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
|
||||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let foundToday = false;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
foundToday = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (foundToday) {
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If today is the last column, use the one before today instead won't work for end resize,
|
||||||
|
// so skip this test in that edge case
|
||||||
|
if (targetX === undefined) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1390,7 +1398,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to a different day column at same Y position
|
// Move to a different day column at same Y position
|
||||||
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
|
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1456,9 +1464,22 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
||||||
// Create entry spanning Wednesday evening → Thursday morning
|
// Create entry spanning today evening → tomorrow morning
|
||||||
const start = toApiTimestamp(wednesdayAt(20));
|
const start = todayAt(20);
|
||||||
const end = toApiTimestamp(thursdayAt(10));
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||||
|
const end = new Date(
|
||||||
|
tomorrow.getFullYear(),
|
||||||
|
tomorrow.getMonth(),
|
||||||
|
tomorrow.getDate(),
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi-day end resize',
|
description: 'Multi-day end resize',
|
||||||
@@ -1467,14 +1488,14 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
// Check if tomorrow column is visible
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
);
|
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find the event segment on Thursday's column
|
// Find the event segment on tomorrow's column
|
||||||
const event = thursdayCol
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi-day end resize' })
|
.filter({ hasText: 'Multi-day end resize' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1520,9 +1541,22 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry spanning Wednesday → Thursday
|
// Create entry spanning today → tomorrow
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(thursdayAt(14));
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||||
|
const end = new Date(
|
||||||
|
tomorrow.getFullYear(),
|
||||||
|
tomorrow.getMonth(),
|
||||||
|
tomorrow.getDate(),
|
||||||
|
14,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Backward end resize multi',
|
description: 'Backward end resize multi',
|
||||||
@@ -1531,16 +1565,29 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
// Check if tomorrow column is visible
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
);
|
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '12:00:00');
|
await scrollCalendarToTime(page, '12:00:00');
|
||||||
|
|
||||||
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
|
// Find today's column header to get its X center
|
||||||
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let todayX: number | undefined;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
todayX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test.skip(todayX === undefined, 'Could not find today column header');
|
||||||
|
|
||||||
// Find event segment on Thursday's column and resize end backward to Wednesday
|
// Find event segment on tomorrow's column and resize end backward to today
|
||||||
const event = thursdayCol
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Backward end resize multi' })
|
.filter({ hasText: 'Backward end resize multi' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1562,9 +1609,9 @@ test.describe('Resize Events', () => {
|
|||||||
await page.mouse.move(centerX, bottomY - 3);
|
await page.mouse.move(centerX, bottomY - 3);
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Drag down a bit first, then move to Wednesday's column at a Y after the start
|
// Drag down a bit first, then move to today's column at a Y after the start
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
||||||
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
|
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1580,8 +1627,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(thursdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(thursdayAt(14));
|
const end = todayAt(14);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'End before start test',
|
description: 'End before start test',
|
||||||
start,
|
start,
|
||||||
@@ -1590,8 +1637,25 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Target Wednesday's column, the day before the entry
|
// Find a column BEFORE today
|
||||||
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let todayIndex = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
todayIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = todayIndex - 1; i >= 0; i--) {
|
||||||
|
const box = await headers.nth(i).boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
test.skip(targetX === undefined, 'No column before today to test');
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1619,7 +1683,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to earlier column at a Y position near the top of the grid (before start time)
|
// Move to earlier column at a Y position near the top of the grid (before start time)
|
||||||
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
|
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
// Wait for any potential API call
|
// Wait for any potential API call
|
||||||
@@ -1668,21 +1732,30 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
// Need today and tomorrow both visible (skip Saturday with Monday week start)
|
||||||
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
|
||||||
|
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
// Use mid-day times so both start and end slots are visible in the viewport
|
// Use mid-day times so both start and end slots are visible in the viewport
|
||||||
await scrollCalendarToTime(page, '10:00:00');
|
await scrollCalendarToTime(page, '10:00:00');
|
||||||
|
|
||||||
// Find Wednesday's and Thursday's columns
|
// Find today's and tomorrow's columns
|
||||||
const wednesdayStr = toDateStr(wednesdayAt(0));
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const thursdayStr = toDateStr(thursdayAt(0));
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
|
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
await expect(wednesdayCol).toBeVisible();
|
await expect(todayCol).toBeVisible();
|
||||||
await expect(thursdayCol).toBeVisible();
|
await expect(tomorrowCol).toBeVisible();
|
||||||
|
|
||||||
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
|
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
|
||||||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||||||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
||||||
await expect(startSlot).toBeVisible();
|
await expect(startSlot).toBeVisible();
|
||||||
@@ -1690,18 +1763,18 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
|
|
||||||
const startSlotBox = await startSlot.boundingBox();
|
const startSlotBox = await startSlot.boundingBox();
|
||||||
const endSlotBox = await endSlot.boundingBox();
|
const endSlotBox = await endSlot.boundingBox();
|
||||||
const wednesdayColBox = await wednesdayCol.boundingBox();
|
const todayColBox = await todayCol.boundingBox();
|
||||||
const thursdayColBox = await thursdayCol.boundingBox();
|
const tomorrowColBox = await tomorrowCol.boundingBox();
|
||||||
|
|
||||||
// Start drag at 11:00 on Wednesday's column
|
// Start drag at 11:00 on today's column
|
||||||
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
|
const startX = todayColBox!.x + todayColBox!.width / 2;
|
||||||
const startY = startSlotBox!.y + 2;
|
const startY = startSlotBox!.y + 2;
|
||||||
|
|
||||||
// End drag at 13:00 on Thursday's column
|
// End drag at 13:00 on tomorrow's column
|
||||||
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
|
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
|
||||||
const endY = endSlotBox!.y + 2;
|
const endY = endSlotBox!.y + 2;
|
||||||
|
|
||||||
// Drag from Wednesday to Thursday, moving down first, then across
|
// Drag from today to tomorrow — move down first, then across
|
||||||
const slotHeight = await getSlotHeight(page);
|
const slotHeight = await getSlotHeight(page);
|
||||||
await page.mouse.move(startX, startY);
|
await page.mouse.move(startX, startY);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
@@ -1719,9 +1792,9 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
await expect(dialog.getByText('Start')).toBeVisible();
|
await expect(dialog.getByText('Start')).toBeVisible();
|
||||||
await expect(dialog.getByText('End')).toBeVisible();
|
await expect(dialog.getByText('End')).toBeVisible();
|
||||||
|
|
||||||
// Start date should be Wednesday, end date should be Thursday
|
// Start date should be today, end date should be tomorrow
|
||||||
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
|
await expect(dialog.getByText(todayStr)).toBeVisible();
|
||||||
await expect(dialog.getByText(thursdayStr)).toBeVisible();
|
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1803,9 +1876,17 @@ test.describe('Timezone & Localization', () => {
|
|||||||
|
|
||||||
test.describe('Multi-Day Events', () => {
|
test.describe('Multi-Day Events', () => {
|
||||||
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
||||||
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
|
// Create entry that spans from today 22:00 to tomorrow 02:00
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
const dayOfWeek = now.getDay();
|
||||||
|
// If today is Saturday (6), the entry would span to next week and may not be visible
|
||||||
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day entry',
|
description: 'Multi day entry',
|
||||||
@@ -1820,8 +1901,14 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day edit test',
|
description: 'Multi day edit test',
|
||||||
@@ -1840,8 +1927,14 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event context menu works', async ({ page, ctx }) => {
|
test('multi-day event context menu works', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day ctx test',
|
description: 'Multi day ctx test',
|
||||||
@@ -1861,11 +1954,16 @@ test.describe('Multi-Day Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
const now = new Date();
|
||||||
const startDate = wednesdayAt(22);
|
const dayOfWeek = now.getDay();
|
||||||
const endDate = thursdayAt(2);
|
// Need today and tomorrow both visible (skip Saturday)
|
||||||
const start = toApiTimestamp(startDate);
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
const end = toApiTimestamp(endDate);
|
|
||||||
|
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag test',
|
description: 'Multi day drag test',
|
||||||
@@ -1875,9 +1973,12 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
|
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const eventBox = await event.boundingBox();
|
const eventBox = await event.boundingBox();
|
||||||
@@ -1912,19 +2013,27 @@ test.describe('Multi-Day Events', () => {
|
|||||||
// Duration must be preserved (4 hours)
|
// Duration must be preserved (4 hours)
|
||||||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||||||
|
|
||||||
// The start should still be on Wednesday (not jumped to Thursday)
|
// The start should still be on today (not jumped to tomorrow)
|
||||||
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
const now = new Date();
|
||||||
const startDate = wednesdayAt(22);
|
const dayOfWeek = now.getDay();
|
||||||
const endDate = thursdayAt(2);
|
// Need today and tomorrow both visible (skip Saturday)
|
||||||
const start = toApiTimestamp(startDate);
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
const end = toApiTimestamp(endDate);
|
|
||||||
|
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag up test',
|
description: 'Multi day drag up test',
|
||||||
@@ -1934,9 +2043,12 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
const event = thursdayCol
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi day drag up test' });
|
.filter({ hasText: 'Multi day drag up test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -10,5 +10,9 @@
|
|||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.6"
|
"ref": "v0.0.6"
|
||||||
|
},
|
||||||
|
"Auditing": {
|
||||||
|
"repository": "solidtime-io/extension-auditing",
|
||||||
|
"ref": "v0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,13 @@ 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 =
|
||||||
| 'name'
|
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
|
||||||
| 'tasks'
|
|
||||||
| 'client_name'
|
|
||||||
| 'spent_time'
|
|
||||||
| 'progress'
|
|
||||||
| 'billable_rate'
|
|
||||||
| 'status'
|
|
||||||
| 'visibility';
|
|
||||||
export type { SortDirection } from '@/utils/useSortableTable';
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
@@ -64,16 +56,6 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { tasks } = useTasksQuery();
|
|
||||||
|
|
||||||
const taskCountMap = computed(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
tasks.value.forEach((task) => {
|
|
||||||
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
@@ -82,11 +64,6 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
|
|||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'tasks',
|
|
||||||
sortDescFirst: true,
|
|
||||||
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
@@ -184,7 +161,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gridTemplate = computed(() => {
|
const gridTemplate = computed(() => {
|
||||||
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ function handleSort(column: SortColumn) {
|
|||||||
@sort="handleSort">
|
@sort="handleSort">
|
||||||
Name
|
Name
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
|
|
||||||
Tasks
|
|
||||||
</SortableTableHeaderCell>
|
|
||||||
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
||||||
Client
|
Client
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
|
|||||||
@@ -88,19 +88,17 @@ const showEditProjectModal = ref(false);
|
|||||||
<ContextMenuTrigger as-child>
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow :href="route('projects.show', { project: project.id })">
|
<TableRow :href="route('projects.show', { project: project.id })">
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||||
<div
|
<div
|
||||||
:style="{ backgroundColor: project.color }"
|
:style="{
|
||||||
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
|
backgroundColor: project.color,
|
||||||
|
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||||
|
}"
|
||||||
|
class="w-3 h-3 ml-1 rounded-full"></div>
|
||||||
<span class="overflow-ellipsis overflow-hidden">
|
<span class="overflow-ellipsis overflow-hidden">
|
||||||
{{ project.name }}
|
{{ project.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
|
||||||
<span v-if="projectTasksCount">
|
|
||||||
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-text-tertiary">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AuditResource;
|
|
||||||
use App\Models\Audit;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
use Tests\Unit\Filament\FilamentTestCase;
|
|
||||||
|
|
||||||
#[UsesClass(AuditResource::class)]
|
|
||||||
class AuditResourceTest extends FilamentTestCase
|
|
||||||
{
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
Config::set('auth.super_admins', ['admin@example.com']);
|
|
||||||
$user = User::factory()->withPersonalOrganization()->create([
|
|
||||||
'email' => 'admin@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_can_list_audits(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
$timeEntry = TimeEntry::factory()->forMember($user->member)->create();
|
|
||||||
DB::table((new Audit)->getTable())->delete();
|
|
||||||
$audits = Audit::factory()->auditFor($timeEntry)->auditUser($user->user)->createMany(5);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = Livewire::test(AuditResource\Pages\ListAudits::class);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
$response->assertCanSeeTableRecords($audits);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_can_see_view_page_of_audit(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
DB::table((new Audit)->getTable())->delete();
|
|
||||||
$audit = Audit::factory()->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = Livewire::test(AuditResource\Pages\ViewAudit::class, ['record' => $audit->getKey()]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertSuccessful();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user