mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Allow updating public_until on already-public reports
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user