Skip to content

Commit d264411

Browse files
committed
Allow updating public_until on already-public reports
1 parent 6668106 commit d264411

File tree

6 files changed

+136
-5
lines changed

6 files changed

+136
-5
lines changed

app/Http/Controllers/Api/V1/ReportController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ public function update(Organization $organization, Report $report, ReportUpdateR
150150
$report->share_secret = null;
151151
$report->public_until = null;
152152
}
153+
} elseif ($report->is_public && $request->has('public_until')) {
154+
// Allow updating expiration date on already-public reports
155+
$report->public_until = $request->getPublicUntil();
153156
}
154157
$report->save();
155158

e2e/shared-reports.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,64 @@ test('test that shared report with No Tag filter shows entries without tags', as
382382
await expect(page.getByText('Reporting')).toBeVisible();
383383
await expect(page.getByText('Total')).toBeVisible();
384384
});
385+
386+
test('test that updating expiration date on already-public report works', async ({ page }) => {
387+
const projectName = 'UpdateExpDateProj ' + Math.floor(Math.random() * 10000);
388+
const reportName = 'UpdateExpDateReport ' + Math.floor(Math.random() * 10000);
389+
390+
await createProject(page, projectName);
391+
await createTimeEntryWithProject(page, projectName, '1h');
392+
393+
await goToReporting(page);
394+
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
395+
396+
// Create a public report (already public by default)
397+
await saveAsSharedReport(page, reportName);
398+
399+
// Go to shared reports and edit
400+
await goToReportingShared(page);
401+
await expect(page.getByText(reportName)).toBeVisible();
402+
403+
// Click more options and edit
404+
await page
405+
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
406+
.click();
407+
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
408+
409+
// The date picker should be visible (report is already public)
410+
const datePicker = page
411+
.getByRole('dialog')
412+
.getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
413+
await expect(datePicker).toBeVisible();
414+
await datePicker.click();
415+
416+
// Select the 25th of next month
417+
const calendarGrid = page.getByRole('grid');
418+
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
419+
await page.getByRole('button', { name: /Next/i }).click();
420+
await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click();
421+
422+
// Wait for the calendar to close
423+
await expect(calendarGrid).not.toBeVisible();
424+
425+
// Update the report and verify it includes the correct public_until date
426+
const [response] = await Promise.all([
427+
page.waitForResponse(
428+
(response) =>
429+
response.url().includes('/reports/') &&
430+
response.request().method() === 'PUT' &&
431+
response.status() === 200
432+
),
433+
page.getByRole('button', { name: 'Update Report' }).click(),
434+
]);
435+
const responseBody = await response.json();
436+
expect(responseBody.data.public_until).toBeTruthy();
437+
438+
// Verify the date is the 25th of a future month
439+
const returnedDate = new Date(responseBody.data.public_until);
440+
expect(returnedDate.getUTCDate()).toBe(25);
441+
442+
// The returned date should be in the future
443+
const now = new Date();
444+
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
445+
});

resources/js/Components/Common/Report/ReportTable.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defineProps<{
1414
}>();
1515
1616
const gridTemplate = computed(() => {
17-
return `grid-template-columns: minmax(150px, auto) minmax(250px, 1fr) minmax(140px, auto) minmax(130px, auto) 80px;`;
17+
return `grid-template-columns: minmax(150px, auto) minmax(200px, 1fr) minmax(100px, 120px) minmax(80px, 100px) minmax(100px, 120px) minmax(130px, auto) 80px;`;
1818
});
1919
</script>
2020

@@ -23,7 +23,7 @@ const gridTemplate = computed(() => {
2323
<div class="inline-block min-w-full align-middle">
2424
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
2525
<ReportTableHeading></ReportTableHeading>
26-
<div v-if="reports.length === 0" class="col-span-5 py-24 text-center">
26+
<div v-if="reports.length === 0" class="col-span-7 py-24 text-center">
2727
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
2828
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
2929
<p v-if="canCreateProjects()" class="pb-5">

resources/js/Components/Common/Report/ReportTableHeading.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
88
Name
99
</div>
1010
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
11+
<div class="px-3 py-1.5 text-left text-text-tertiary">Created At</div>
1112
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
13+
<div class="px-3 py-1.5 text-left text-text-tertiary">Expires At</div>
1214
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
1315
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
1416
<span class="sr-only">Edit</span>

resources/js/Components/Common/Report/ReportTableRow.vue

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue';
2+
import { type ComputedRef, computed, inject, ref } from 'vue';
33
import TableRow from '@/Components/TableRow.vue';
4-
import { api, type Report } from '@/packages/api/src';
4+
import { api, type Report, type Organization } from '@/packages/api/src';
55
import ReportMoreOptionsDropdown from '@/Components/Common/Report/ReportMoreOptionsDropdown.vue';
66
import ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';
77
import { SecondaryButton } from '@/packages/ui/src';
88
import { useClipboard } from '@vueuse/core';
99
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
10+
import { GlobeAltIcon, LockClosedIcon } from '@heroicons/vue/24/outline';
1011
import { useMutation, useQueryClient } from '@tanstack/vue-query';
1112
import { getCurrentOrganizationId } from '@/utils/useUser';
1213
import { useNotificationsStore } from '@/utils/notification';
14+
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
1315
1416
const props = defineProps<{
1517
report: Report;
@@ -19,6 +21,8 @@ const showEditReportModal = ref(false);
1921
2022
const { copy, copied, isSupported } = useClipboard({ legacy: true });
2123
const { handleApiRequestNotifications } = useNotificationsStore();
24+
const organization = inject<ComputedRef<Organization | undefined>>('organization');
25+
const dateFormat = computed(() => organization?.value?.date_format);
2226
2327
function openSharableLink() {
2428
const link = props.report.shareable_link;
@@ -71,7 +75,19 @@ async function deleteReport() {
7175
</span>
7276
</div>
7377
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
74-
{{ report.is_public ? 'Public' : 'Private' }}
78+
{{ formatDateLocalized(report.created_at, dateFormat) }}
79+
</div>
80+
<div
81+
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex items-center gap-1.5">
82+
<GlobeAltIcon v-if="report.is_public" class="w-4 h-4 shrink-0 text-text-tertiary" />
83+
<LockClosedIcon v-else class="w-4 h-4 shrink-0 text-text-tertiary" />
84+
<span>{{ report.is_public ? 'Public' : 'Private' }}</span>
85+
</div>
86+
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
87+
<span v-if="report.public_until">
88+
{{ formatDateLocalized(report.public_until, dateFormat) }}
89+
</span>
90+
<span v-else>Never</span>
7591
</div>
7692
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
7793
<div v-if="report.shareable_link" class="space-x-2 flex items-center">

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,60 @@ public function test_update_endpoint_can_update_the_report_all_properties_set():
425425
->where('data.name', 'Updated Report')
426426
->where('data.description', 'Updated description')
427427
->where('data.is_public', true)
428+
->whereType('data.public_until', 'string')
428429
->where('data.properties.group', TimeEntryAggregationType::Project->value)
429430
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
430431
);
431432
}
432433

434+
public function test_update_endpoint_can_update_public_until_on_already_public_report(): void
435+
{
436+
// Arrange
437+
$data = $this->createUserWithPermission([
438+
'reports:update',
439+
]);
440+
$report = Report::factory()->public()->forOrganization($data->organization)->create([
441+
'public_until' => null,
442+
]);
443+
Passport::actingAs($data->user);
444+
$newPublicUntil = Carbon::now()->addDays(30);
445+
446+
// Act
447+
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
448+
'public_until' => $newPublicUntil->toIso8601ZuluString(),
449+
]);
450+
451+
// Assert
452+
$response->assertStatus(200);
453+
$report->refresh();
454+
$this->assertTrue($report->is_public);
455+
$this->assertNotNull($report->public_until);
456+
$this->assertTrue($newPublicUntil->isSameDay($report->public_until));
457+
}
458+
459+
public function test_update_endpoint_can_clear_public_until_on_already_public_report(): void
460+
{
461+
// Arrange
462+
$data = $this->createUserWithPermission([
463+
'reports:update',
464+
]);
465+
$report = Report::factory()->public()->forOrganization($data->organization)->create([
466+
'public_until' => Carbon::now()->addDays(30),
467+
]);
468+
Passport::actingAs($data->user);
469+
470+
// Act
471+
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
472+
'public_until' => null,
473+
]);
474+
475+
// Assert
476+
$response->assertStatus(200);
477+
$report->refresh();
478+
$this->assertTrue($report->is_public);
479+
$this->assertNull($report->public_until);
480+
}
481+
433482
public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void
434483
{
435484
// Arrange

0 commit comments

Comments
 (0)