mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 19:52:15 +01: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->share_secret = null;
|
||||||
$report->public_until = 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();
|
$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('Reporting')).toBeVisible();
|
||||||
await expect(page.getByText('Total')).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(() => {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const gridTemplate = computed(() => {
|
|||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
|
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
|
||||||
<ReportTableHeading></ReportTableHeading>
|
<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>
|
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||||
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
|
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
|
||||||
<p v-if="canCreateProjects()" class="pb-5">
|
<p v-if="canCreateProjects()" class="pb-5">
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
|||||||
Name
|
Name
|
||||||
</div>
|
</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">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">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="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">
|
<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>
|
<span class="sr-only">Edit</span>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { type ComputedRef, computed, inject, ref } from 'vue';
|
||||||
import TableRow from '@/Components/TableRow.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 ReportMoreOptionsDropdown from '@/Components/Common/Report/ReportMoreOptionsDropdown.vue';
|
||||||
import ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';
|
import ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';
|
||||||
import { SecondaryButton } from '@/packages/ui/src';
|
import { SecondaryButton } from '@/packages/ui/src';
|
||||||
import { useClipboard } from '@vueuse/core';
|
import { useClipboard } from '@vueuse/core';
|
||||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||||
|
import { GlobeAltIcon, LockClosedIcon } from '@heroicons/vue/24/outline';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
report: Report;
|
report: Report;
|
||||||
@@ -19,6 +21,8 @@ const showEditReportModal = ref(false);
|
|||||||
|
|
||||||
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
const { copy, copied, isSupported } = useClipboard({ legacy: true });
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
const organization = inject<ComputedRef<Organization | undefined>>('organization');
|
||||||
|
const dateFormat = computed(() => organization?.value?.date_format);
|
||||||
|
|
||||||
function openSharableLink() {
|
function openSharableLink() {
|
||||||
const link = props.report.shareable_link;
|
const link = props.report.shareable_link;
|
||||||
@@ -71,7 +75,19 @@ async function deleteReport() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<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>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
<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">
|
<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.name', 'Updated Report')
|
||||||
->where('data.description', 'Updated description')
|
->where('data.description', 'Updated description')
|
||||||
->where('data.is_public', true)
|
->where('data.is_public', true)
|
||||||
|
->whereType('data.public_until', 'string')
|
||||||
->where('data.properties.group', TimeEntryAggregationType::Project->value)
|
->where('data.properties.group', TimeEntryAggregationType::Project->value)
|
||||||
->where('data.properties.sub_group', TimeEntryAggregationType::Task->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
|
public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user