Skip to content

Commit 7266272

Browse files
committed
Add client_ids filter to time entry export
1 parent 0d3978a commit 7266272

File tree

8 files changed

+125
-34
lines changed

8 files changed

+125
-34
lines changed

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Enums\ExportFormat;
88
use App\Enums\TimeEntryRoundingType;
9+
use App\Models\Client;
910
use App\Models\Member;
1011
use App\Models\Organization;
1112
use App\Models\Project;
@@ -58,6 +59,23 @@ public function rules(): array
5859
return $builder->whereBelongsTo($this->organization, 'organization');
5960
}),
6061
],
62+
// Filter by client IDs, client IDs are OR combined
63+
'client_ids' => [
64+
'array',
65+
'min:1',
66+
],
67+
'client_ids.*' => [
68+
'string',
69+
function (string $attribute, mixed $value, \Closure $fail): void {
70+
if ($value === TimeEntryFilter::NONE_VALUE) {
71+
return;
72+
}
73+
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
74+
/** @var Builder<Client> $builder */
75+
return $builder->whereBelongsTo($this->organization, 'organization');
76+
})->uuid()->validate($attribute, $value, $fail);
77+
},
78+
],
6179
// Filter by project IDs, project IDs are OR combined
6280
'project_ids' => [
6381
'array',

e2e/members.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ test('test that merging a placeholder member works', async ({ page }) => {
205205
page.getByRole('button', { name: 'Merge Member' }).click(),
206206
page.waitForResponse(
207207
(response) =>
208-
response.url().includes('/member/') && response.url().includes('/merge-into') && response.ok()
208+
response.url().includes('/member/') &&
209+
response.url().includes('/merge-into') &&
210+
response.ok()
209211
),
210212
]);
211213

e2e/utils/reporting.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,7 @@ export async function createClient(page: Page, clientName: string) {
5252
await expect(page.getByText(clientName)).toBeVisible();
5353
}
5454

55-
export async function createProjectWithClient(
56-
page: Page,
57-
projectName: string,
58-
clientName: string
59-
) {
55+
export async function createProjectWithClient(page: Page, projectName: string, clientName: string) {
6056
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
6157
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
6258
await page.getByRole('button', { name: 'Create Project' }).click();

resources/js/Components/Common/Reporting/ReportingFilterBar.vue

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@ async function createTag(name: string) {
5151
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
5252
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
5353
<div class="text-sm font-medium">Filters</div>
54-
<MemberMultiselectDropdown
55-
v-model="selectedMembers"
56-
@submit="emit('submit')">
54+
<MemberMultiselectDropdown v-model="selectedMembers" @submit="emit('submit')">
5755
<template #trigger>
5856
<ReportingFilterBadge
5957
:count="selectedMembers.length"
@@ -62,9 +60,7 @@ async function createTag(name: string) {
6260
:icon="UserGroupIcon" />
6361
</template>
6462
</MemberMultiselectDropdown>
65-
<ProjectMultiselectDropdown
66-
v-model="selectedProjects"
67-
@submit="emit('submit')">
63+
<ProjectMultiselectDropdown v-model="selectedProjects" @submit="emit('submit')">
6864
<template #trigger>
6965
<ReportingFilterBadge
7066
:count="selectedProjects.length"
@@ -73,9 +69,7 @@ async function createTag(name: string) {
7369
:icon="FolderIcon" />
7470
</template>
7571
</ProjectMultiselectDropdown>
76-
<TaskMultiselectDropdown
77-
v-model="selectedTasks"
78-
@submit="emit('submit')">
72+
<TaskMultiselectDropdown v-model="selectedTasks" @submit="emit('submit')">
7973
<template #trigger>
8074
<ReportingFilterBadge
8175
:count="selectedTasks.length"
@@ -84,9 +78,7 @@ async function createTag(name: string) {
8478
:icon="CheckCircleIcon" />
8579
</template>
8680
</TaskMultiselectDropdown>
87-
<ClientMultiselectDropdown
88-
v-model="selectedClients"
89-
@submit="emit('submit')">
81+
<ClientMultiselectDropdown v-model="selectedClients" @submit="emit('submit')">
9082
<template #trigger>
9183
<ReportingFilterBadge
9284
:count="selectedClients.length"

resources/js/Components/Common/Reporting/ReportingOverview.vue

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,13 @@ const groupedPieChartData = computed(() => {
182182
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
183183
const name = getNameForReportingRowEntry(
184184
entry.key,
185-
aggregatedTableTimeEntries.value?.grouped_type
185+
aggregatedTableTimeEntries.value?.grouped_type ?? null
186186
);
187187
let color = getRandomColorWithSeed(entry.key ?? 'none');
188188
if (
189189
name &&
190190
aggregatedTableTimeEntries.value?.grouped_type &&
191-
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type] === name
191+
emptyPlaceholder[aggregatedTableTimeEntries.value.grouped_type] === name
192192
) {
193193
color = '#CCCCCC';
194194
} else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {
@@ -200,7 +200,7 @@ const groupedPieChartData = computed(() => {
200200
name:
201201
getNameForReportingRowEntry(
202202
entry.key,
203-
aggregatedTableTimeEntries.value?.grouped_type
203+
aggregatedTableTimeEntries.value?.grouped_type ?? null
204204
) ?? '',
205205
color: color,
206206
};
@@ -215,7 +215,7 @@ const tableData = computed(() => {
215215
cost: entry.cost,
216216
description: getNameForReportingRowEntry(
217217
entry.key,
218-
aggregatedTableTimeEntries.value?.grouped_type
218+
aggregatedTableTimeEntries.value?.grouped_type ?? null
219219
),
220220
grouped_data:
221221
entry.grouped_data?.map((el) => {
@@ -256,13 +256,12 @@ const tableData = computed(() => {
256256
v-model:rounding-type="roundingType"
257257
v-model:rounding-minutes="roundingMinutes"
258258
v-model:start-date="startDate"
259-
v-model:end-date="endDate"
260-
/>
259+
v-model:end-date="endDate" />
261260
<MainContainer>
262261
<div class="pt-10 w-full px-3 relative">
263262
<ReportingChart
264-
:grouped-type="aggregatedGraphTimeEntries?.grouped_type"
265-
:grouped-data="aggregatedGraphTimeEntries?.grouped_data"></ReportingChart>
263+
:grouped-type="aggregatedGraphTimeEntries?.grouped_type ?? null"
264+
:grouped-data="aggregatedGraphTimeEntries?.grouped_data ?? null"></ReportingChart>
266265
</div>
267266
</MainContainer>
268267
<MainContainer>
@@ -273,13 +272,13 @@ const tableData = computed(() => {
273272
<span>Group by</span>
274273
<ReportingGroupBySelect
275274
v-model="group"
276-
:group-by-options="groupByOptions"
277-
></ReportingGroupBySelect>
275+
:group-by-options="groupByOptions"></ReportingGroupBySelect>
278276
<span>and</span>
279277
<ReportingGroupBySelect
280278
v-model="subGroup"
281-
:group-by-options="groupByOptions.filter((el) => el.value !== group)"
282-
></ReportingGroupBySelect>
279+
:group-by-options="
280+
groupByOptions.filter((el) => el.value !== group)
281+
"></ReportingGroupBySelect>
283282
</div>
284283
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
285284
<div

resources/js/packages/ui/src/Input/MultiselectDropdown.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts" generic="T">
22
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
3-
import { computed, ref, watch } from 'vue';
3+
import { computed, type Ref, ref, watch } from 'vue';
44
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
55
import {
66
ComboboxAnchor,
@@ -27,7 +27,7 @@ const props = defineProps<{
2727
2828
const open = ref(false);
2929
const searchValue = ref('');
30-
const sortedItems = ref<T[]>([]);
30+
const sortedItems = ref<T[]>([]) as Ref<T[]>;
3131
3232
watch(open, (isOpen) => {
3333
if (isOpen) {
@@ -43,7 +43,9 @@ watch(open, (isOpen) => {
4343
const filteredItems = computed(() => {
4444
const search = searchValue.value.toLowerCase().trim();
4545
if (!search) return sortedItems.value;
46-
return sortedItems.value.filter((item) => props.getNameForItem(item).toLowerCase().includes(search));
46+
return sortedItems.value.filter((item) =>
47+
props.getNameForItem(item).toLowerCase().includes(search)
48+
);
4749
});
4850
4951
const showNoItem = computed(() => {

resources/js/utils/useAggregatedTimeEntriesQuery.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { useQuery } from '@tanstack/vue-query';
2-
import { api, type AggregatedTimeEntriesQueryParams, type ReportingResponse } from '@/packages/api/src';
2+
import {
3+
api,
4+
type AggregatedTimeEntriesQueryParams,
5+
type ReportingResponse,
6+
} from '@/packages/api/src';
37
import { getCurrentOrganizationId } from '@/utils/useUser';
48
import { computed, type ComputedRef, unref } from 'vue';
59

tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,84 @@ public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_emplo
15131513
$this->assertResponseCode($response, 200);
15141514
}
15151515

1516+
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
1517+
{
1518+
// Arrange
1519+
$data = $this->createUserWithPermission([
1520+
'time-entries:view:all',
1521+
]);
1522+
$clientA = Client::factory()->forOrganization($data->organization)->create();
1523+
$clientB = Client::factory()->forOrganization($data->organization)->create();
1524+
$projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create();
1525+
$projectB = Project::factory()->forOrganization($data->organization)->forClient($clientB)->create();
1526+
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectA)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
1527+
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectB)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
1528+
Passport::actingAs($data->user);
1529+
1530+
// Act
1531+
$response = $this->getJson(route('api.v1.time-entries.index-export', [
1532+
$data->organization->getKey(),
1533+
'format' => ExportFormat::CSV,
1534+
'client_ids' => [$clientA->getKey()],
1535+
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
1536+
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
1537+
]));
1538+
1539+
// Assert
1540+
$this->assertResponseCode($response, 200);
1541+
}
1542+
1543+
public function test_index_export_endpoint_with_none_client_ids_filter_succeeds(): void
1544+
{
1545+
// Arrange
1546+
$data = $this->createUserWithPermission([
1547+
'time-entries:view:all',
1548+
]);
1549+
$client = Client::factory()->forOrganization($data->organization)->create();
1550+
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
1551+
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
1552+
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
1553+
Passport::actingAs($data->user);
1554+
1555+
// Act
1556+
$response = $this->getJson(route('api.v1.time-entries.index-export', [
1557+
$data->organization->getKey(),
1558+
'format' => ExportFormat::CSV,
1559+
'client_ids' => [TimeEntryFilter::NONE_VALUE],
1560+
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
1561+
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
1562+
]));
1563+
1564+
// Assert
1565+
$this->assertResponseCode($response, 200);
1566+
}
1567+
1568+
public function test_index_export_endpoint_with_client_ids_of_other_organization_fails_validation(): void
1569+
{
1570+
// Arrange
1571+
$data = $this->createUserWithPermission([
1572+
'time-entries:view:all',
1573+
]);
1574+
$otherData = $this->createUserWithPermission([
1575+
'time-entries:view:all',
1576+
]);
1577+
$otherClient = Client::factory()->forOrganization($otherData->organization)->create();
1578+
Passport::actingAs($data->user);
1579+
1580+
// Act
1581+
$response = $this->getJson(route('api.v1.time-entries.index-export', [
1582+
$data->organization->getKey(),
1583+
'format' => ExportFormat::CSV,
1584+
'client_ids' => [$otherClient->getKey()],
1585+
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
1586+
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
1587+
]));
1588+
1589+
// Assert
1590+
$response->assertStatus(422);
1591+
$response->assertJsonValidationErrorFor('client_ids.0');
1592+
}
1593+
15161594
public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
15171595
{
15181596
// Arrange

0 commit comments

Comments
 (0)