mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-17 18:23:19 +01:00
Compare commits
9 Commits
f00d48f1c3
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95bd15864a | ||
|
|
8f446306ef | ||
|
|
e74af760dd | ||
|
|
425175c43e | ||
|
|
28aba7b27a | ||
|
|
453d5ed066 | ||
|
|
ed3ba0135b | ||
|
|
fb7d945c50 | ||
|
|
9958e1c09c |
@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
|||||||
])
|
])
|
||||||
->whereHas('user', function (Builder $query): void {
|
->whereHas('user', function (Builder $query): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
$query->where('is_placeholder', '=', false);
|
$query->where('is_placeholder', '=', false)
|
||||||
|
->where('send_time_entry_still_running_email', '=', true);
|
||||||
})
|
})
|
||||||
->orderBy('created_at', 'asc')
|
->orderBy('created_at', 'asc')
|
||||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class ExportController extends Controller
|
|||||||
|
|
||||||
$filepath = $exportService->export($organization);
|
$filepath = $exportService->export($organization);
|
||||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
|
||||||
|
]);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -336,7 +336,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +549,9 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'download_url' => Storage::disk(config('filesystems.private'))
|
'download_url' => Storage::disk(config('filesystems.private'))
|
||||||
->temporaryUrl($path, now()->addMinutes(5)),
|
->temporaryUrl($path, now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ class UserController extends Controller
|
|||||||
$user->week_start = $request->getWeekStart();
|
$user->week_start = $request->getWeekStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
|
||||||
|
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
|
||||||
|
}
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if ($emailToVerify !== null) {
|
if ($emailToVerify !== null) {
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
'week_start' => [
|
'week_start' => [
|
||||||
Rule::enum(Weekday::class),
|
Rule::enum(Weekday::class),
|
||||||
],
|
],
|
||||||
|
'send_time_entry_still_running_email' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSendTimeEntryStillRunningEmail(): ?bool
|
||||||
|
{
|
||||||
|
return $this->has('send_time_entry_still_running_email')
|
||||||
|
? $this->boolean('send_time_entry_still_running_email')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
public function hasPhotoKey(): bool
|
public function hasPhotoKey(): bool
|
||||||
{
|
{
|
||||||
return $this->has('photo');
|
return $this->has('photo');
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class UserResource extends BaseResource
|
|||||||
'timezone' => $this->resource->timezone,
|
'timezone' => $this->resource->timezone,
|
||||||
/** @var Weekday $week_start Starting day of the week */
|
/** @var Weekday $week_start Starting day of the week */
|
||||||
'week_start' => $this->resource->week_start->value,
|
'week_start' => $this->resource->week_start->value,
|
||||||
|
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||||
|
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $timezone
|
* @property string $timezone
|
||||||
* @property bool $is_placeholder
|
* @property bool $is_placeholder
|
||||||
* @property Weekday $week_start
|
* @property Weekday $week_start
|
||||||
|
* @property bool $send_time_entry_still_running_email
|
||||||
* @property string|null $profile_photo_path
|
* @property string|null $profile_photo_path
|
||||||
* @property-read Organization|null $currentOrganization
|
* @property-read Organization|null $currentOrganization
|
||||||
* @property-read string $profile_photo_url
|
* @property-read string $profile_photo_url
|
||||||
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
'week_start' => Weekday::class,
|
'week_start' => Weekday::class,
|
||||||
|
'send_time_entry_still_running_email' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
*/
|
*/
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'week_start' => Weekday::Monday,
|
'week_start' => Weekday::Monday,
|
||||||
|
'send_time_entry_still_running_email' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Service\BillingContract;
|
|||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use App\Service\IpLookup\NoIpLookupService;
|
use App\Service\IpLookup\NoIpLookupService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
|
use DateTimeInterface;
|
||||||
use Dedoc\Scramble\Scramble;
|
use Dedoc\Scramble\Scramble;
|
||||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||||
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||||
$this->app->bind(BillingContract::class);
|
$this->app->bind(BillingContract::class);
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||||
|
// so mirror it through the signed query parameters of the storage route.
|
||||||
|
$privateDisk = config('filesystems.private');
|
||||||
|
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
|
||||||
|
$disk = Storage::disk($privateDisk);
|
||||||
|
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
|
||||||
|
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
|
||||||
|
});
|
||||||
|
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
|
||||||
|
$parameters = array_filter([
|
||||||
|
'path' => $path,
|
||||||
|
'disposition' => isset($options['ResponseContentDisposition'])
|
||||||
|
? Str::before($options['ResponseContentDisposition'], ';')
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
Route::model('member', Member::class);
|
Route::model('member', Member::class);
|
||||||
Route::model('invitation', OrganizationInvitation::class);
|
Route::model('invitation', OrganizationInvitation::class);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('send_time_entry_still_running_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
|
|||||||
.locator('xpath=ancestor::*[descendant::form][1]');
|
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationSettingsForm(page: Page) {
|
||||||
|
return page
|
||||||
|
.getByRole('heading', { name: 'Notifications', exact: true })
|
||||||
|
.locator('xpath=ancestor::*[descendant::form][1]');
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProfileForm(page: Page): Promise<void> {
|
async function saveProfileForm(page: Page): Promise<void> {
|
||||||
const form = profileInformationForm(page);
|
const form = profileInformationForm(page);
|
||||||
await form.getByRole('button', { name: 'Save' }).click();
|
await form.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('still-running email notification setting persists across reload', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const form = notificationSettingsForm(page);
|
||||||
|
const checkbox = form.getByLabel('Still-running time entry reminders');
|
||||||
|
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
await checkbox.uncheck();
|
||||||
|
await form.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1288,3 +1288,36 @@ test.describe('Projects Pagination', () => {
|
|||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that searching projects by name works', async ({ page, ctx }) => {
|
||||||
|
const suffix = Math.floor(1 + Math.random() * 10000);
|
||||||
|
const matchingProjectName = 'Searchable Project ' + suffix;
|
||||||
|
const otherProjectName = 'Unrelated Work ' + suffix;
|
||||||
|
await createProjectViaApi(ctx, { name: matchingProjectName });
|
||||||
|
await createProjectViaApi(ctx, { name: otherProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearTableState(page, 'project-table-state');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
|
||||||
|
|
||||||
|
// Searching is case insensitive and matches part of the name
|
||||||
|
await searchInput.fill('SEARCHABLE');
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||||
|
|
||||||
|
// A term that matches nothing empties the table
|
||||||
|
await searchInput.fill('no project has this name');
|
||||||
|
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).not.toBeVisible();
|
||||||
|
await expect(page.getByText('No matching projects')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Clearing the search restores both projects
|
||||||
|
await searchInput.fill('');
|
||||||
|
await expect(page.getByText(matchingProjectName)).toBeVisible();
|
||||||
|
await expect(page.getByText(otherProjectName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 '@/Components/Common/Pagination.vue';
|
||||||
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
| 'name'
|
| 'name'
|
||||||
@@ -34,12 +35,17 @@ import {
|
|||||||
|
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
projects: Project[];
|
defineProps<{
|
||||||
showBillableRate: boolean;
|
projects: Project[];
|
||||||
sortColumn: SortColumn;
|
showBillableRate: boolean;
|
||||||
sortDirection: SortDirection;
|
sortColumn: SortColumn;
|
||||||
}>();
|
sortDirection: SortDirection;
|
||||||
|
isFiltered?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ isFiltered: false, isLoading: false }
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
sort: [column: SortColumn, direction: SortDirection];
|
sort: [column: SortColumn, direction: SortDirection];
|
||||||
@@ -128,6 +134,28 @@ const paginatedProjects = computed(() => {
|
|||||||
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emptyState = computed(() => {
|
||||||
|
if (props.isFiltered) {
|
||||||
|
return {
|
||||||
|
title: 'No matching projects',
|
||||||
|
description: 'Try a different search term or adjust your filters.',
|
||||||
|
showCreateButton: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!canCreateProjects()) {
|
||||||
|
return {
|
||||||
|
title: 'You are not a member of any projects',
|
||||||
|
description: 'Ask your manager to add you to a project as a team member.',
|
||||||
|
showCreateButton: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'No projects found',
|
||||||
|
description: 'Create your first project now!',
|
||||||
|
showCreateButton: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -161,24 +189,21 @@ const gridTemplate = computed(() => {
|
|||||||
:sort-direction="props.sortDirection"
|
:sort-direction="props.sortDirection"
|
||||||
:desc-first-columns="descFirstColumns"
|
:desc-first-columns="descFirstColumns"
|
||||||
@sort="handleSort"></ProjectTableHeading>
|
@sort="handleSort"></ProjectTableHeading>
|
||||||
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
|
<div
|
||||||
|
v-if="props.isLoading"
|
||||||
|
class="col-span-full flex justify-center items-center py-24">
|
||||||
|
<LoadingSpinner></LoadingSpinner>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="sortedProjects.length === 0"
|
||||||
|
class="col-span-full py-24 text-center">
|
||||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">
|
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
|
||||||
{{
|
|
||||||
canCreateProjects()
|
|
||||||
? 'No projects found'
|
|
||||||
: 'You are not a member of any projects'
|
|
||||||
}}
|
|
||||||
</h3>
|
|
||||||
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
|
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
|
||||||
{{
|
{{ emptyState.description }}
|
||||||
canCreateProjects()
|
|
||||||
? 'Create your first project now!'
|
|
||||||
: 'Ask your manager to add you to a project as a team member.'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="canCreateProjects()"
|
v-if="emptyState.showCreateButton"
|
||||||
:icon="PlusIcon"
|
:icon="PlusIcon"
|
||||||
@click="showCreateProjectModal = true"
|
@click="showCreateProjectModal = true"
|
||||||
>Create your First Project
|
>Create your First Project
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function handleSort(column: SortColumn) {
|
|||||||
<template>
|
<template>
|
||||||
<TableHeading>
|
<TableHeading>
|
||||||
<SortableTableHeaderCell
|
<SortableTableHeaderCell
|
||||||
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
|
class="pr-3 pl-2 sm:pl-4 lg:pl-6"
|
||||||
column="name"
|
column="name"
|
||||||
v-bind="sortState"
|
v-bind="sortState"
|
||||||
@sort="handleSort">
|
@sort="handleSort">
|
||||||
@@ -58,7 +58,7 @@ function handleSort(column: SortColumn) {
|
|||||||
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
|
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
|
||||||
Visibility
|
Visibility
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-2 sm:pr-4 lg:pr-6">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHeading>
|
</TableHeading>
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ 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-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
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="{
|
:style="{
|
||||||
backgroundColor: project.color,
|
backgroundColor: project.color,
|
||||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||||
}"
|
}"
|
||||||
class="w-3 h-3 rounded-full"></div>
|
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>
|
||||||
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-2 sm:pr-4 lg:pr-6">
|
||||||
<ProjectMoreOptionsDropdown
|
<ProjectMoreOptionsDropdown
|
||||||
:project="project"
|
:project="project"
|
||||||
@edit="showEditProjectModal = true"
|
@edit="showEditProjectModal = true"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
|
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
|
||||||
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
||||||
import {
|
import {
|
||||||
@@ -81,23 +81,18 @@ function toggleNoClient() {
|
|||||||
clientIds,
|
clientIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = computed(() => {
|
|
||||||
return (
|
|
||||||
props.filters.status !== 'all' ||
|
|
||||||
props.filters.visibility !== 'all' ||
|
|
||||||
props.filters.clientIds.length > 0
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-model:open="open">
|
<DropdownMenu v-model:open="open">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="xs" aria-label="Filter projects">
|
<!-- -ml-1.5 cancels the icon's inset inside the button so it sits flush with the toolbar's left padding -->
|
||||||
<ListFilterIcon
|
<Button
|
||||||
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
|
variant="ghost"
|
||||||
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
|
size="icon"
|
||||||
|
class="-ml-1.5 h-7 w-7 flex-shrink-0"
|
||||||
|
aria-label="Filter projects">
|
||||||
|
<ListFilterIcon class="h-4 w-4 text-icon-default" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" class="w-56">
|
<DropdownMenuContent align="start" class="w-56">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
|
|||||||
|
|
||||||
function downloadCurrentExport() {
|
function downloadCurrentExport() {
|
||||||
if (props.exportUrl) {
|
if (props.exportUrl) {
|
||||||
window.open(props.exportUrl, '_blank')?.focus();
|
window.open(props.exportUrl, '_self');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
103
resources/js/Pages/Profile/Partials/NotificationSettingsForm.vue
Normal file
103
resources/js/Pages/Profile/Partials/NotificationSettingsForm.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||||
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@/packages/ui/src/field';
|
||||||
|
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
|
||||||
|
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
|
||||||
|
|
||||||
|
const { user } = useUserQuery();
|
||||||
|
const updateUser = useUpdateUserMutation();
|
||||||
|
|
||||||
|
const sendTimeEntryStillRunningEmail = ref(true);
|
||||||
|
const recentlySaved = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
user,
|
||||||
|
(currentUser, previousUser) => {
|
||||||
|
if (currentUser && previousUser === undefined) {
|
||||||
|
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUserLoaded = computed(() => user.value !== undefined);
|
||||||
|
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
|
||||||
|
const fieldErrors = computed<Record<string, string>>(() =>
|
||||||
|
getApiValidationFieldErrors(updateUser.error.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (isSaveDisabled.value || !user.value) return;
|
||||||
|
|
||||||
|
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
|
||||||
|
flashSaved();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await updateUser.mutateAsync({
|
||||||
|
userId: user.value.id,
|
||||||
|
body: {
|
||||||
|
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
|
||||||
|
flashSaved();
|
||||||
|
} catch {
|
||||||
|
// 422: field errors render below. Other errors: toast handled in the mutation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashSaved() {
|
||||||
|
recentlySaved.value = true;
|
||||||
|
setTimeout(() => (recentlySaved.value = false), 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormSection @submitted="save">
|
||||||
|
<template #title>Notifications</template>
|
||||||
|
|
||||||
|
<template #description>Choose which email notifications you want to receive.</template>
|
||||||
|
|
||||||
|
<template #form>
|
||||||
|
<div class="col-span-6 sm:col-span-4">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="send_time_entry_still_running_email"
|
||||||
|
v-model:checked="sendTimeEntryStillRunningEmail"
|
||||||
|
:disabled="!isUserLoaded" />
|
||||||
|
<FieldContent>
|
||||||
|
<FieldLabel for="send_time_entry_still_running_email">
|
||||||
|
Still-running time entry reminders
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldDescription>
|
||||||
|
Email me when a time entry has been running for more than 8 hours.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
|
||||||
|
{{ fieldErrors.send_time_entry_still_running_email }}
|
||||||
|
</FieldError>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
|
||||||
|
|
||||||
|
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
|
||||||
|
Save
|
||||||
|
</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</FormSection>
|
||||||
|
</template>
|
||||||
@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
|
|||||||
import type { Session } from '@/types/jetstream';
|
import type { Session } from '@/types/jetstream';
|
||||||
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
|
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
|
||||||
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
|
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
|
||||||
|
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
confirmsTwoFactorAuthentication: boolean;
|
confirmsTwoFactorAuthentication: boolean;
|
||||||
@@ -36,6 +37,12 @@ defineProps<{
|
|||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NotificationSettingsForm />
|
||||||
|
|
||||||
|
<SectionBorder />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { Search } from '@lucide/vue';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
import { computed } from 'vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
@@ -27,7 +29,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
|||||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||||
|
|
||||||
// Fetch data using TanStack Query
|
// Fetch data using TanStack Query
|
||||||
const { projects } = useProjectsQuery();
|
const { projects, isLoading: projectsLoading } = useProjectsQuery();
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
|
|
||||||
@@ -62,9 +64,19 @@ const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableSta
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Not persisted, so a reload never starts silently filtered
|
||||||
|
const search = ref('');
|
||||||
|
|
||||||
// Filter projects based on current filters
|
// Filter projects based on current filters
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
|
const searchTerm = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
return projects.value.filter((project) => {
|
return projects.value.filter((project) => {
|
||||||
|
// Name search
|
||||||
|
if (searchTerm && !project.name.toLowerCase().includes(searchTerm)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
if (tableState.value.filters.status === 'active' && project.is_archived) {
|
||||||
return false;
|
return false;
|
||||||
@@ -98,6 +110,15 @@ const filteredProjects = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() => {
|
||||||
|
return (
|
||||||
|
search.value.trim() !== '' ||
|
||||||
|
tableState.value.filters.status !== 'all' ||
|
||||||
|
tableState.value.filters.visibility !== 'all' ||
|
||||||
|
tableState.value.filters.clientIds.length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Helper functions for active filters
|
// Helper functions for active filters
|
||||||
function removeStatusFilter() {
|
function removeStatusFilter() {
|
||||||
tableState.value.filters.status = 'all';
|
tableState.value.filters.status = 'all';
|
||||||
@@ -130,8 +151,7 @@ const showBillableRate = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Projects" data-testid="projects_view">
|
<AppLayout title="Projects" data-testid="projects_view">
|
||||||
<MainContainer
|
<MainContainer class="py-3 sm:pt-5 flex justify-between items-center">
|
||||||
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
|
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,11 +204,25 @@ const showBillableRate = computed(() => {
|
|||||||
:clients="clients"
|
:clients="clients"
|
||||||
@remove="removeClientFilter"
|
@remove="removeClientFilter"
|
||||||
@update:value="tableState.filters.clientIds = $event as string[]" />
|
@update:value="tableState.filters.clientIds = $event as string[]" />
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-icon-default" />
|
||||||
|
<TextInput
|
||||||
|
v-model="search"
|
||||||
|
size="sm"
|
||||||
|
type="search"
|
||||||
|
aria-label="Search projects"
|
||||||
|
placeholder="Search projects..."
|
||||||
|
class="w-60 border-transparent bg-transparent pl-7 shadow-none placeholder:text-text-tertiary hover:bg-black/5 focus-visible:bg-input-background dark:hover:bg-white/5 [&::-webkit-search-cancel-button]:hidden" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
|
||||||
<ProjectTable
|
<ProjectTable
|
||||||
:show-billable-rate="showBillableRate"
|
:show-billable-rate="showBillableRate"
|
||||||
|
:is-filtered="hasActiveFilters"
|
||||||
|
:is-loading="projectsLoading"
|
||||||
:projects="filteredProjects"
|
:projects="filteredProjects"
|
||||||
:sort-column="tableState.sortColumn"
|
:sort-column="tableState.sortColumn"
|
||||||
:sort-direction="tableState.sortDirection"
|
:sort-direction="tableState.sortDirection"
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ const UserResource = z
|
|||||||
profile_photo_url: z.string(),
|
profile_photo_url: z.string(),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
week_start: Weekday,
|
week_start: Weekday,
|
||||||
|
send_time_entry_still_running_email: z.boolean(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const UserUpdateRequest = z
|
const UserUpdateRequest = z
|
||||||
@@ -714,6 +715,7 @@ const UserUpdateRequest = z
|
|||||||
photo: z.union([z.string(), z.null()]),
|
photo: z.union([z.string(), z.null()]),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
week_start: Weekday,
|
week_start: Weekday,
|
||||||
|
send_time_entry_still_running_email: z.boolean(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
1
resources/js/types/models.d.ts
vendored
1
resources/js/types/models.d.ts
vendored
@@ -76,6 +76,7 @@ export interface User {
|
|||||||
two_factor_confirmed_at: string | null;
|
two_factor_confirmed_at: string | null;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
week_start: string;
|
week_start: string;
|
||||||
|
send_time_entry_still_running_email: boolean;
|
||||||
profile_photo_url: string;
|
profile_photo_url: string;
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
clients: Client[];
|
clients: Client[];
|
||||||
|
|||||||
@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame("Sending still running time entry emails...\n".
|
$this->assertSame("Sending still running time entry emails...\n".
|
||||||
"Finished sending 0 still running time entry emails...\n", $output);
|
"Finished sending 0 still running time entry emails...\n", $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_does_not_send_emails_to_users_who_disabled_them(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$user->user->send_time_entry_still_running_email = false;
|
||||||
|
$user->user->save();
|
||||||
|
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
|
||||||
|
'start' => Carbon::now()->subHours(8)->subSecond(),
|
||||||
|
'end' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Mail::assertNothingOutgoing();
|
||||||
|
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
|
||||||
|
$this->assertSame(Command::SUCCESS, $exitCode);
|
||||||
|
$this->assertSame("Sending still running time entry emails...\n".
|
||||||
|
"Finished sending 0 still running time entry emails...\n", Artisan::output());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Testing\TestResponse;
|
use Illuminate\Testing\TestResponse;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
@@ -16,4 +19,26 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
|
|||||||
}
|
}
|
||||||
$response->assertStatus($statusCode);
|
$response->assertStatus($statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the temporary URL builder of the private disk to capture the options
|
||||||
|
* passed to temporaryUrl. Returns a closure that yields the captured options.
|
||||||
|
*
|
||||||
|
* @return Closure(): (array<string, mixed>|null)
|
||||||
|
*/
|
||||||
|
protected function captureTemporaryUrlOptions(): Closure
|
||||||
|
{
|
||||||
|
$captured = null;
|
||||||
|
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
|
||||||
|
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
|
||||||
|
$captured = $options;
|
||||||
|
|
||||||
|
return 'https://storage.fake/'.$path;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return function () use (&$captured): ?array {
|
||||||
|
return $captured;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,4 +90,35 @@ class ExportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_export_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission([
|
||||||
|
'export',
|
||||||
|
]);
|
||||||
|
$filepath = 'exports/export_test.zip';
|
||||||
|
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
|
||||||
|
$mock->shouldReceive('export')
|
||||||
|
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||||
|
return $organization->is($user->organization);
|
||||||
|
})
|
||||||
|
->andReturn($filepath)
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.export.export', [
|
||||||
|
'organization' => $user->organization->getKey(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertSame('attachment; filename="export_test.zip"', $options['ResponseContentDisposition'] ?? null);
|
||||||
|
$this->assertSame('https://storage.fake/'.$filepath, $response->json('download_url'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -947,6 +947,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_index_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^attachment; filename="time-entries-export-.+\.csv"$/',
|
||||||
|
$options['ResponseContentDisposition'] ?? ''
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1321,6 +1350,38 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertResponseCode($response, 200);
|
$this->assertResponseCode($response, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
'format' => ExportFormat::CSV,
|
||||||
|
'group' => TimeEntryAggregationType::Client,
|
||||||
|
'sub_group' => TimeEntryAggregationType::Project,
|
||||||
|
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||||
|
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertResponseCode($response, 200);
|
||||||
|
$options = $capturedOptions();
|
||||||
|
$this->assertIsArray($options);
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^attachment; filename="time-entries-report-.+\.csv"$/',
|
||||||
|
$options['ResponseContentDisposition'] ?? ''
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'profile_photo_url' => $data->user->profile_photo_url,
|
'profile_photo_url' => $data->user->profile_photo_url,
|
||||||
'timezone' => $data->user->timezone,
|
'timezone' => $data->user->timezone,
|
||||||
'week_start' => $data->user->week_start->value,
|
'week_start' => $data->user->week_start->value,
|
||||||
|
'send_time_entry_still_running_email' => true,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonValidationErrors('organization_id');
|
$response->assertJsonValidationErrors('organization_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_changes_user_name_timezone_and_week_start(): void
|
public function test_update_changes_user_profile_settings(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission();
|
$data = $this->createUserWithPermission();
|
||||||
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => 'Updated Name',
|
'name' => 'Updated Name',
|
||||||
'timezone' => 'America/New_York',
|
'timezone' => 'America/New_York',
|
||||||
'week_start' => Weekday::Sunday->value,
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
'send_time_entry_still_running_email' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'name' => 'Updated Name',
|
'name' => 'Updated Name',
|
||||||
'timezone' => 'America/New_York',
|
'timezone' => 'America/New_York',
|
||||||
'week_start' => Weekday::Sunday->value,
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
'send_time_entry_still_running_email' => false,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertSame('Updated Name', $user->name);
|
$this->assertSame('Updated Name', $user->name);
|
||||||
$this->assertSame('America/New_York', $user->timezone);
|
$this->assertSame('America/New_York', $user->timezone);
|
||||||
$this->assertSame(Weekday::Sunday, $user->week_start);
|
$this->assertSame(Weekday::Sunday, $user->week_start);
|
||||||
|
$this->assertFalse($user->send_time_entry_still_running_email);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
||||||
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonValidationErrors(['week_start']);
|
$response->assertJsonValidationErrors(['week_start']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'send_time_entry_still_running_email' => 'not-a-boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_fails_if_photo_is_not_a_string(): void
|
public function test_update_fails_if_photo_is_not_a_string(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Endpoint\Web;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the attachment downloads of the private disk via Laravel's storage.{disk} route,
|
||||||
|
* configured with serveUsing/buildTemporaryUrlsUsing in AppServiceProvider::boot.
|
||||||
|
* These tests use the real private disk, because Storage::fake would replace the disk
|
||||||
|
* instance and thereby remove that configuration.
|
||||||
|
*/
|
||||||
|
class FileDownloadEndpointTest extends EndpointTestAbstract
|
||||||
|
{
|
||||||
|
private function privateDisk(): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk(config('filesystems.private'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_with_attachment_disposition_serves_file_as_attachment(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$disk = $this->privateDisk();
|
||||||
|
$disk->put('exports/test-attachment.csv', 'Description,Duration');
|
||||||
|
$url = $disk->temporaryUrl('exports/test-attachment.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-attachment.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertDownload('test-attachment.csv');
|
||||||
|
$disk->delete('exports/test-attachment.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_without_options_serves_file_inline(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$disk = $this->privateDisk();
|
||||||
|
$disk->put('exports/test-inline.csv', 'Description,Duration');
|
||||||
|
$url = $disk->temporaryUrl('exports/test-inline.csv', now()->addMinutes(5));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertStringStartsWith('inline', (string) $response->headers->get('Content-Disposition'));
|
||||||
|
$disk->delete('exports/test-inline.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_with_tampered_disposition(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-tampered.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-tampered.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get(str_replace('attachment', 'inline', $url));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_with_expired_signature(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-expired.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-expired.csv"',
|
||||||
|
]);
|
||||||
|
$this->travel(6)->minutes();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_download_fails_if_file_does_not_exist(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-missing.csv', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-missing.csv"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($url);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_of_private_local_disk_points_to_storage_route_with_signed_disposition(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$url = $this->privateDisk()->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertStringContainsString('/storage/exports/test-export.pdf', $url);
|
||||||
|
$this->assertStringContainsString('disposition=attachment', $url);
|
||||||
|
$this->assertStringContainsString('signature=', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_temporary_url_of_s3_disk_includes_content_disposition_from_options(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config([
|
||||||
|
'filesystems.disks.s3.key' => 'test-key',
|
||||||
|
'filesystems.disks.s3.secret' => 'test-secret',
|
||||||
|
'filesystems.disks.s3.region' => 'us-east-1',
|
||||||
|
'filesystems.disks.s3.bucket' => 'test-bucket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$url = Storage::disk('s3')->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||||
|
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||||
|
$this->assertSame('attachment; filename="test-export.pdf"', $query['response-content-disposition'] ?? null);
|
||||||
|
$this->assertArrayHasKey('X-Amz-Signature', $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user