Allow updating public_until on already-public reports

This commit is contained in:
Gregor Vostrak
2026-02-05 15:48:06 +01:00
parent a154293348
commit c17d87b710
6 changed files with 136 additions and 5 deletions
@@ -150,6 +150,9 @@ class ReportController extends Controller
$report->share_secret = null;
$report->public_until = null;
}
} elseif ($report->is_public && $request->has('public_until')) {
// Allow updating expiration date on already-public reports
$report->public_until = $request->getPublicUntil();
}
$report->save();
+61
View File
@@ -382,3 +382,64 @@ test('test that shared report with No Tag filter shows entries without tags', as
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that updating expiration date on already-public report works', async ({ page }) => {
const projectName = 'UpdateExpDateProj ' + Math.floor(Math.random() * 10000);
const reportName = 'UpdateExpDateReport ' + Math.floor(Math.random() * 10000);
await createProject(page, projectName);
await createTimeEntryWithProject(page, projectName, '1h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Create a public report (already public by default)
await saveAsSharedReport(page, reportName);
// Go to shared reports and edit
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
// Click more options and edit
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
// The date picker should be visible (report is already public)
const datePicker = page
.getByRole('dialog')
.getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
await expect(datePicker).toBeVisible();
await datePicker.click();
// Select the 25th of next month
const calendarGrid = page.getByRole('grid');
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click();
// Wait for the calendar to close
await expect(calendarGrid).not.toBeVisible();
// Update the report and verify it includes the correct public_until date
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
const responseBody = await response.json();
expect(responseBody.data.public_until).toBeTruthy();
// Verify the date is the 25th of a future month
const returnedDate = new Date(responseBody.data.public_until);
expect(returnedDate.getUTCDate()).toBe(25);
// The returned date should be in the future
const now = new Date();
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
});
@@ -14,7 +14,7 @@ defineProps<{
}>();
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(150px, auto) minmax(250px, 1fr) minmax(140px, auto) minmax(130px, auto) 80px;`;
return `grid-template-columns: minmax(150px, auto) minmax(200px, 1fr) minmax(100px, 120px) minmax(80px, 100px) minmax(100px, 120px) minmax(130px, auto) 80px;`;
});
</script>
@@ -23,7 +23,7 @@ const gridTemplate = computed(() => {
<div class="inline-block min-w-full align-middle">
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
<ReportTableHeading></ReportTableHeading>
<div v-if="reports.length === 0" class="col-span-5 py-24 text-center">
<div v-if="reports.length === 0" class="col-span-7 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
<p v-if="canCreateProjects()" class="pb-5">
@@ -8,7 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Created At</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Expires At</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import { type ComputedRef, computed, inject, ref } from 'vue';
import TableRow from '@/Components/TableRow.vue';
import { api, type Report } from '@/packages/api/src';
import { api, type Report, type Organization } from '@/packages/api/src';
import ReportMoreOptionsDropdown from '@/Components/Common/Report/ReportMoreOptionsDropdown.vue';
import ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';
import { SecondaryButton } from '@/packages/ui/src';
import { useClipboard } from '@vueuse/core';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { GlobeAltIcon, LockClosedIcon } from '@heroicons/vue/24/outline';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
const props = defineProps<{
report: Report;
@@ -19,6 +21,8 @@ const showEditReportModal = ref(false);
const { copy, copied, isSupported } = useClipboard({ legacy: true });
const { handleApiRequestNotifications } = useNotificationsStore();
const organization = inject<ComputedRef<Organization | undefined>>('organization');
const dateFormat = computed(() => organization?.value?.date_format);
function openSharableLink() {
const link = props.report.shareable_link;
@@ -71,7 +75,19 @@ async function deleteReport() {
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ report.is_public ? 'Public' : 'Private' }}
{{ formatDateLocalized(report.created_at, dateFormat) }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex items-center gap-1.5">
<GlobeAltIcon v-if="report.is_public" class="w-4 h-4 shrink-0 text-text-tertiary" />
<LockClosedIcon v-else class="w-4 h-4 shrink-0 text-text-tertiary" />
<span>{{ report.is_public ? 'Public' : 'Private' }}</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<span v-if="report.public_until">
{{ formatDateLocalized(report.public_until, dateFormat) }}
</span>
<span v-else>Never</span>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<div v-if="report.shareable_link" class="space-x-2 flex items-center">
@@ -425,11 +425,60 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
->where('data.name', 'Updated Report')
->where('data.description', 'Updated description')
->where('data.is_public', true)
->whereType('data.public_until', 'string')
->where('data.properties.group', TimeEntryAggregationType::Project->value)
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
);
}
public function test_update_endpoint_can_update_public_until_on_already_public_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->public()->forOrganization($data->organization)->create([
'public_until' => null,
]);
Passport::actingAs($data->user);
$newPublicUntil = Carbon::now()->addDays(30);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'public_until' => $newPublicUntil->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(200);
$report->refresh();
$this->assertTrue($report->is_public);
$this->assertNotNull($report->public_until);
$this->assertTrue($newPublicUntil->isSameDay($report->public_until));
}
public function test_update_endpoint_can_clear_public_until_on_already_public_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->public()->forOrganization($data->organization)->create([
'public_until' => Carbon::now()->addDays(30),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'public_until' => null,
]);
// Assert
$response->assertStatus(200);
$report->refresh();
$this->assertTrue($report->is_public);
$this->assertNull($report->public_until);
}
public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void
{
// Arrange