Add website annotations with chart markers and CRUD management
This commit is contained in:
1 parent
eea124bbf8
commit
64e4cc1c3b
24 files changed
+957
-3
No files matched your search
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "annotation" (
|
||||
"annotation_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"user_id" UUID,
|
||||
"date" TIMESTAMPTZ(6) NOT NULL,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT true,
|
||||
"note" VARCHAR(500) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "annotation_pkey" PRIMARY KEY ("annotation_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "annotation_annotation_id_key" ON "annotation"("annotation_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "annotation_website_id_date_idx" ON "annotation"("website_id", "date");
|
||||
@@ -97,6 +97,7 @@ model Website {
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
annotations Annotation[]
|
||||
eventData EventData[]
|
||||
reports Report[]
|
||||
revenue Revenue[]
|
||||
@@ -269,6 +270,22 @@ model Report {
|
||||
@@map("report")
|
||||
}
|
||||
|
||||
model Annotation {
|
||||
id String @id() @map("annotation_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
date DateTime @db.Timestamptz(6)
|
||||
allDay Boolean @default(true) @map("all_day")
|
||||
note String @db.VarChar(500)
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@index([websiteId, date])
|
||||
@@map("annotation")
|
||||
}
|
||||
|
||||
model Segment {
|
||||
id String @id() @map("segment_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"filters-enabled": "Filters enabled",
|
||||
"property-filter": "Property Filter",
|
||||
"activity-by-property": "Activity by property",
|
||||
"add-note": "Add note",
|
||||
"first-click": "First click",
|
||||
"first-seen": "First seen",
|
||||
"funnel": "Funnel",
|
||||
@@ -361,6 +362,8 @@
|
||||
"arpu": "ARPU",
|
||||
"uniqueCustomers": "Unique Customers",
|
||||
"customers": "Customers",
|
||||
"note": "Note",
|
||||
"notes": "Notes",
|
||||
"orders": "Orders",
|
||||
"saved": "Saved",
|
||||
"unknown": "Unknown",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ChartAnnotation } from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange, useTimezone } from '@/components/hooks';
|
||||
import {
|
||||
useDateParameters,
|
||||
useDateRange,
|
||||
useNavigation,
|
||||
useTimezone,
|
||||
useWebsiteAnnotationsQuery,
|
||||
} from '@/components/hooks';
|
||||
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
|
||||
import { PageviewsChart } from '@/components/metrics/PageviewsChart';
|
||||
import { getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
|
||||
export function WebsiteChart({
|
||||
websiteId,
|
||||
@@ -11,9 +19,16 @@ export function WebsiteChart({
|
||||
websiteId: string;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { timezone } = useTimezone();
|
||||
const { timezone, localFromUtc } = useTimezone();
|
||||
const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
|
||||
const { startDate, endDate, unit, value } = dateRange;
|
||||
const { startAt, endAt } = useDateParameters();
|
||||
const { router, updateParams } = useNavigation();
|
||||
const { data: annotationData } = useWebsiteAnnotationsQuery(websiteId, {
|
||||
startAt,
|
||||
endAt,
|
||||
pageSize: 1000,
|
||||
});
|
||||
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
|
||||
websiteId,
|
||||
compare: compareMode ? dateCompare?.compare : undefined,
|
||||
@@ -45,6 +60,27 @@ export function WebsiteChart({
|
||||
};
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
const annotations = useMemo<ChartAnnotation[]>(() => {
|
||||
return (annotationData?.data || []).map(({ id, date, note, allDay }) => ({
|
||||
id,
|
||||
date: localFromUtc(new Date(date)),
|
||||
label: note,
|
||||
allDay,
|
||||
}));
|
||||
}, [annotationData, timezone]);
|
||||
|
||||
const handleAnnotationClick = useCallback(
|
||||
(annotation: ChartAnnotation) => {
|
||||
router.push(
|
||||
updateParams({
|
||||
date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false),
|
||||
offset: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[router, updateParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
|
||||
<PageviewsChart
|
||||
@@ -53,6 +89,8 @@ export function WebsiteChart({
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
unit={unit}
|
||||
annotations={annotations}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
</LoadingPanel>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Column, Row } from '@umami/react-zen';
|
||||
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { UnitFilter } from '@/components/input/UnitFilter';
|
||||
import { AnnotationsButton } from './annotations/AnnotationsButton';
|
||||
import { WebsiteChart } from './WebsiteChart';
|
||||
import { WebsiteControls } from './WebsiteControls';
|
||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||
@@ -18,6 +19,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||
<UnitFilter />
|
||||
</Row>
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
<Row justifyContent="end">
|
||||
<AnnotationsButton websiteId={websiteId} />
|
||||
</Row>
|
||||
</Panel>
|
||||
<WebsitePanels websiteId={websiteId} />
|
||||
<ExpandedViewModal websiteId={websiteId} />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
|
||||
export function AnnotationDeleteButton({
|
||||
annotationId,
|
||||
websiteId,
|
||||
name,
|
||||
onSave,
|
||||
}: {
|
||||
annotationId: string;
|
||||
websiteId: string;
|
||||
name: string;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const { mutateAsync, isPending, error, touch } = useDeleteQuery(
|
||||
`/websites/${websiteId}/annotations/${annotationId}`,
|
||||
);
|
||||
|
||||
const handleConfirm = async (close: () => void) => {
|
||||
await mutateAsync(null, {
|
||||
onSuccess: () => {
|
||||
touch('annotations');
|
||||
onSave?.();
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogButton icon={<Trash />} variant="quiet" title={t(labels.confirm)} width="400px">
|
||||
{({ close }) => (
|
||||
<ConfirmationForm
|
||||
message={t.rich(messages.confirmRemove, {
|
||||
target: name,
|
||||
b: chunks => <b>{chunks}</b>,
|
||||
})}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
onClose={close}
|
||||
buttonLabel={t(labels.delete)}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
DatePicker,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
Label,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { startOfDay } from 'date-fns';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useMessages, useTimezone, useUpdateQuery } from '@/components/hooks';
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
date: string;
|
||||
allDay: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export function AnnotationEditForm({
|
||||
annotation,
|
||||
websiteId,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
annotation?: Annotation;
|
||||
websiteId: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels, messages, getErrorMessage } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { localToUtc, localFromUtc } = useTimezone();
|
||||
|
||||
const initialDate = useMemo(
|
||||
() => (annotation ? localFromUtc(new Date(annotation.date)) : new Date()),
|
||||
[annotation],
|
||||
);
|
||||
const [date, setDate] = useState<Date>(startOfDay(initialDate));
|
||||
|
||||
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
|
||||
`/websites/${websiteId}/annotations${annotation ? `/${annotation.id}` : ''}`,
|
||||
);
|
||||
|
||||
const handleSubmit = async ({ note }: { note: string }) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
date: localToUtc(startOfDay(date)).toISOString(),
|
||||
allDay: true,
|
||||
note,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast(t(messages.saved));
|
||||
touch('annotations');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
error={getErrorMessage(error)}
|
||||
onSubmit={handleSubmit}
|
||||
defaultValues={{ note: annotation?.note || '' }}
|
||||
>
|
||||
<Column gap>
|
||||
<Column>
|
||||
<Label>{t(labels.date)}</Label>
|
||||
<DatePicker value={date} locale={locale} onChange={value => value && setDate(value)} />
|
||||
</Column>
|
||||
|
||||
<FormField
|
||||
name="note"
|
||||
label={t(labels.note)}
|
||||
rules={{ required: t(labels.required), maxLength: 500 }}
|
||||
>
|
||||
<TextField asTextArea autoFocus />
|
||||
</FormField>
|
||||
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Column>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMessages, useShare } from '@/components/hooks';
|
||||
import { MessageSquareText } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { AnnotationsModal } from './AnnotationsModal';
|
||||
|
||||
export function AnnotationsButton({ websiteId }: { websiteId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const share = useShare();
|
||||
|
||||
if (share) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
icon={<MessageSquareText />}
|
||||
label={t(labels.notes)}
|
||||
variant="quiet"
|
||||
width="800px"
|
||||
>
|
||||
{({ close }) => <AnnotationsModal websiteId={websiteId} onClose={close} />}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Button, Column, DataColumn, DataTable, Loading, Row, Text } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { formatAnnotationDate } from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { Pager } from '@/components/common/Pager';
|
||||
import {
|
||||
useLocale,
|
||||
useMessages,
|
||||
useNavigation,
|
||||
useTimezone,
|
||||
useWebsiteAnnotationsQuery,
|
||||
} from '@/components/hooks';
|
||||
import { Edit, Plus } from '@/components/icons';
|
||||
import { getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
import { AnnotationDeleteButton } from './AnnotationDeleteButton';
|
||||
import { type Annotation, AnnotationEditForm } from './AnnotationEditForm';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type View = { mode: 'list' } | { mode: 'add' } | { mode: 'edit'; annotation: Annotation };
|
||||
|
||||
export function AnnotationsModal({
|
||||
websiteId,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { localFromUtc } = useTimezone();
|
||||
const { router, updateParams } = useNavigation();
|
||||
const [view, setView] = useState<View>({ mode: 'list' });
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading } = useWebsiteAnnotationsQuery(websiteId, {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const showList = () => setView({ mode: 'list' });
|
||||
|
||||
const toLocalDate = (annotation: Annotation) => localFromUtc(new Date(annotation.date));
|
||||
|
||||
const handleSelect = (annotation: Annotation) => {
|
||||
router.push(
|
||||
updateParams({
|
||||
date: getAnnotationDateRangeValue(toLocalDate(annotation), annotation.allDay),
|
||||
offset: undefined,
|
||||
}),
|
||||
);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
if (view.mode !== 'list') {
|
||||
return (
|
||||
<AnnotationEditForm
|
||||
websiteId={websiteId}
|
||||
annotation={view.mode === 'edit' ? view.annotation : undefined}
|
||||
onSave={showList}
|
||||
onClose={showList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
const rows: Annotation[] = data?.data || [];
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row justifyContent="end">
|
||||
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
|
||||
<Plus />
|
||||
{t(labels.addNote)}
|
||||
</Button>
|
||||
</Row>
|
||||
{rows.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<DataTable data={rows}>
|
||||
<DataColumn id="date" label={t(labels.date)} width="200px">
|
||||
{(row: Annotation) => (
|
||||
<Button variant="quiet" onPress={() => handleSelect(row)}>
|
||||
{formatAnnotationDate(
|
||||
{ id: row.id, date: toLocalDate(row), label: row.note, allDay: row.allDay },
|
||||
locale,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="note" label={t(labels.note)}>
|
||||
{(row: Annotation) => <Text style={{ whiteSpace: 'pre-wrap' }}>{row.note}</Text>}
|
||||
</DataColumn>
|
||||
<DataColumn id="action" align="end" width="100px">
|
||||
{(row: Annotation) => (
|
||||
<Row>
|
||||
<Button variant="quiet" onPress={() => setView({ mode: 'edit', annotation: row })}>
|
||||
<IconLabel icon={<Edit />} />
|
||||
</Button>
|
||||
<AnnotationDeleteButton
|
||||
annotationId={row.id}
|
||||
websiteId={websiteId}
|
||||
name={row.note}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
)}
|
||||
{data?.count > PAGE_SIZE && (
|
||||
<Pager page={page} pageSize={PAGE_SIZE} count={data.count} onPageChange={setPage} />
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, notFound, ok, unauthorized } from '@/lib/response';
|
||||
import { annotationSchema } from '@/lib/schema';
|
||||
import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions';
|
||||
import { deleteAnnotation, getWebsiteAnnotation, updateAnnotation } from '@/queries/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; annotationId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, annotationId } = await params;
|
||||
|
||||
if (!(await canViewSharedWebsiteFilters(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const annotation = await getWebsiteAnnotation(websiteId, annotationId);
|
||||
|
||||
if (!annotation) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return json(annotation);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; annotationId: string }> },
|
||||
) {
|
||||
const { auth, body, error } = await parseRequest(request, annotationSchema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, annotationId } = await params;
|
||||
const { date, allDay, note } = body;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const annotation = await getWebsiteAnnotation(websiteId, annotationId);
|
||||
|
||||
if (!annotation) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const result = await updateAnnotation(annotationId, { date, allDay, note });
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; annotationId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId, annotationId } = await params;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const annotation = await getWebsiteAnnotation(websiteId, annotationId);
|
||||
|
||||
if (!annotation) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
await deleteAnnotation(annotationId);
|
||||
|
||||
return ok();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions';
|
||||
import { createAnnotation, getWebsiteAnnotations } from '@/queries/prisma';
|
||||
import { GET, POST } from './route';
|
||||
|
||||
vi.mock('@/lib/request', () => ({
|
||||
parseRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/permissions', () => ({
|
||||
canUpdateWebsite: vi.fn(),
|
||||
canViewSharedWebsiteFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/queries/prisma', () => ({
|
||||
createAnnotation: vi.fn(),
|
||||
getWebsiteAnnotations: vi.fn(),
|
||||
}));
|
||||
|
||||
const parseRequestMock = vi.mocked(parseRequest);
|
||||
const canUpdateWebsiteMock = vi.mocked(canUpdateWebsite);
|
||||
const canViewSharedWebsiteFiltersMock = vi.mocked(canViewSharedWebsiteFilters);
|
||||
const createAnnotationMock = vi.mocked(createAnnotation);
|
||||
const getWebsiteAnnotationsMock = vi.mocked(getWebsiteAnnotations);
|
||||
|
||||
const params = Promise.resolve({ websiteId: 'website-1' });
|
||||
|
||||
beforeEach(() => {
|
||||
parseRequestMock.mockReset();
|
||||
canUpdateWebsiteMock.mockReset();
|
||||
canViewSharedWebsiteFiltersMock.mockReset();
|
||||
createAnnotationMock.mockReset();
|
||||
getWebsiteAnnotationsMock.mockReset();
|
||||
});
|
||||
|
||||
test('POST validates the note and stores the annotation with the creating user', async () => {
|
||||
const date = new Date('2026-08-01T00:00:00.000Z');
|
||||
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: { user: { id: 'user-1' } },
|
||||
body: { date, allDay: false, note: 'Deployed v2' },
|
||||
error: undefined,
|
||||
} as any);
|
||||
canUpdateWebsiteMock.mockResolvedValue(true);
|
||||
createAnnotationMock.mockResolvedValue({ id: 'annotation-1' } as any);
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/websites/website-1/annotations', { method: 'POST' }),
|
||||
{ params },
|
||||
);
|
||||
const schema = parseRequestMock.mock.calls[0][1] as {
|
||||
safeParse: (value: unknown) => { success: boolean; data?: any };
|
||||
};
|
||||
|
||||
expect(schema.safeParse({ date: '2026-08-01', note: '' }).success).toBe(false);
|
||||
expect(schema.safeParse({ date: '2026-08-01', note: 'x'.repeat(501) }).success).toBe(false);
|
||||
expect(schema.safeParse({ date: 'not-a-date', note: 'ok' }).success).toBe(false);
|
||||
|
||||
const parsed = schema.safeParse({ date: '2026-08-01', note: 'ok' });
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.allDay).toBe(true);
|
||||
|
||||
expect(createAnnotationMock).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
websiteId: 'website-1',
|
||||
userId: 'user-1',
|
||||
date,
|
||||
allDay: false,
|
||||
note: 'Deployed v2',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST rejects users without update permission', async () => {
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: { user: { id: 'user-1' } },
|
||||
body: { date: new Date(), allDay: true, note: 'nope' },
|
||||
error: undefined,
|
||||
} as any);
|
||||
canUpdateWebsiteMock.mockResolvedValue(false);
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/websites/website-1/annotations', { method: 'POST' }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(createAnnotationMock).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test('GET passes the date window through to the query only when both bounds are present', async () => {
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: { user: { id: 'user-1' } },
|
||||
query: { startAt: 1000, endAt: 2000, search: 'deploy', page: 2, pageSize: 20 },
|
||||
error: undefined,
|
||||
} as any);
|
||||
canViewSharedWebsiteFiltersMock.mockResolvedValue(true);
|
||||
getWebsiteAnnotationsMock.mockResolvedValue({ data: [], count: 0 } as any);
|
||||
|
||||
await GET(new Request('http://localhost/api/websites/website-1/annotations'), { params });
|
||||
|
||||
expect(getWebsiteAnnotationsMock).toHaveBeenCalledWith('website-1', {
|
||||
search: 'deploy',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
startDate: new Date(1000),
|
||||
endDate: new Date(2000),
|
||||
});
|
||||
|
||||
parseRequestMock.mockResolvedValue({
|
||||
auth: { user: { id: 'user-1' } },
|
||||
query: { page: 1 },
|
||||
error: undefined,
|
||||
} as any);
|
||||
|
||||
await GET(new Request('http://localhost/api/websites/website-1/annotations'), { params });
|
||||
|
||||
expect(getWebsiteAnnotationsMock).toHaveBeenLastCalledWith('website-1', {
|
||||
search: undefined,
|
||||
page: 1,
|
||||
pageSize: undefined,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { annotationSchema, pagingParams, searchParams } from '@/lib/schema';
|
||||
import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions';
|
||||
import { createAnnotation, getWebsiteAnnotations } from '@/queries/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const schema = z.object({
|
||||
startAt: z.coerce.number().optional(),
|
||||
endAt: z.coerce.number().optional(),
|
||||
...searchParams,
|
||||
...pagingParams,
|
||||
});
|
||||
|
||||
const { auth, query, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { startAt, endAt, search, page, pageSize } = query;
|
||||
|
||||
if (!(await canViewSharedWebsiteFilters(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const annotations = await getWebsiteAnnotations(websiteId, {
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
...(startAt && endAt && { startDate: new Date(startAt), endDate: new Date(endAt) }),
|
||||
});
|
||||
|
||||
return json(annotations);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
const { auth, body, error } = await parseRequest(request, annotationSchema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { date, allDay, note } = body;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const result = await createAnnotation({
|
||||
id: uuid(),
|
||||
websiteId,
|
||||
userId: auth.user.id,
|
||||
date,
|
||||
allDay,
|
||||
note,
|
||||
});
|
||||
|
||||
return json(result);
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import ChartJS, {
|
||||
type UpdateMode,
|
||||
} from 'chart.js/auto';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnnotationMarker,
|
||||
type ChartAnnotation,
|
||||
ChartAnnotationMarkers,
|
||||
} from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { Legend } from '@/components/metrics/Legend';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
|
||||
@@ -20,6 +25,21 @@ export interface ChartProps extends BoxProps {
|
||||
onTooltip?: (model: any) => void;
|
||||
hiddenLabels?: Set<string>;
|
||||
onLegendClick?: (label: string, willBeHidden: boolean) => void;
|
||||
annotations?: ChartAnnotation[];
|
||||
onAnnotationClick?: (annotation: ChartAnnotation) => void;
|
||||
}
|
||||
|
||||
function isSameMarkers(a: AnnotationMarker[], b: AnnotationMarker[]) {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(m, i) =>
|
||||
m.x === b[i].x &&
|
||||
m.y === b[i].y &&
|
||||
m.annotations.length === b[i].annotations.length &&
|
||||
m.annotations.every((annotation, j) => annotation === b[i].annotations[j]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function Chart({
|
||||
@@ -31,11 +51,56 @@ export function Chart({
|
||||
chartOptions,
|
||||
hiddenLabels,
|
||||
onLegendClick,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
...props
|
||||
}: ChartProps) {
|
||||
const canvas = useRef(null);
|
||||
const chart = useRef(null);
|
||||
const [legendItems, setLegendItems] = useState([]);
|
||||
const [markers, setMarkers] = useState<AnnotationMarker[]>([]);
|
||||
const annotationsRef = useRef<ChartAnnotation[]>(annotations);
|
||||
annotationsRef.current = annotations;
|
||||
|
||||
// Computes pixel positions for annotation markers whenever the chart lays out (update / resize)
|
||||
const annotationPlugin = useMemo(
|
||||
() => ({
|
||||
id: 'annotationMarkers',
|
||||
afterLayout: (instance: any) => {
|
||||
const list = annotationsRef.current;
|
||||
const { chartArea, scales } = instance;
|
||||
|
||||
if (!list?.length || !chartArea || !scales?.x) {
|
||||
setMarkers(prev => (prev.length ? [] : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group annotations that land on the same pixel column into a single marker
|
||||
const groups = new Map<number, AnnotationMarker>();
|
||||
|
||||
for (const annotation of list) {
|
||||
const x = Math.round(scales.x.getPixelForValue(annotation.date.getTime()));
|
||||
|
||||
if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = groups.get(x);
|
||||
|
||||
if (group) {
|
||||
group.annotations.push(annotation);
|
||||
} else {
|
||||
groups.set(x, { annotations: [annotation], x, y: chartArea.bottom });
|
||||
}
|
||||
}
|
||||
|
||||
const next = [...groups.values()];
|
||||
|
||||
setMarkers(prev => (isSameMarkers(prev, next) ? prev : next));
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
@@ -99,6 +164,7 @@ export function Chart({
|
||||
type,
|
||||
data: chartData,
|
||||
options,
|
||||
plugins: [annotationPlugin],
|
||||
});
|
||||
|
||||
setLegendItems(chart.current.legend.legendItems);
|
||||
@@ -140,11 +206,19 @@ export function Chart({
|
||||
}
|
||||
}, [chartData, options, updateMode, hiddenLabels]);
|
||||
|
||||
// Re-run layout when annotations change so marker positions are recalculated
|
||||
useEffect(() => {
|
||||
chart.current?.update('none');
|
||||
}, [annotations]);
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<Box {...props}>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<canvas ref={canvas} style={{ position: 'absolute', top: 0, left: 0 }} />
|
||||
{markers.length > 0 && (
|
||||
<ChartAnnotationMarkers markers={markers} onClick={onAnnotationClick} />
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
<Legend items={legendItems} onClick={handleLegendClick} />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--base-color-1, #fff);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.marker:hover,
|
||||
.marker:focus-visible {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
color: #fff;
|
||||
background: inherit;
|
||||
border: 1px solid var(--base-color-1, #fff);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
max-width: 300px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from '@/components/hooks';
|
||||
import { getThemeColors } from '@/lib/colors';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import styles from './ChartAnnotationMarkers.module.css';
|
||||
|
||||
export interface ChartAnnotation {
|
||||
id: string;
|
||||
date: Date;
|
||||
label: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
export interface AnnotationMarker {
|
||||
annotations: ChartAnnotation[];
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function formatAnnotationDate(annotation: ChartAnnotation, locale: string) {
|
||||
return formatDate(annotation.date, annotation.allDay === false ? 'PP p' : 'PP', locale);
|
||||
}
|
||||
|
||||
export function ChartAnnotationMarkers({
|
||||
markers,
|
||||
onClick,
|
||||
}: {
|
||||
markers: AnnotationMarker[];
|
||||
onClick?: (annotation: ChartAnnotation) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ annotations, x, y }) => {
|
||||
const [first] = annotations;
|
||||
|
||||
return (
|
||||
<TooltipTrigger key={first.id} delay={0}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{ left: x, top: y, backgroundColor: colors.chart.views.hoverBorderColor }}
|
||||
aria-label={annotations.map(({ label }) => label).join(', ')}
|
||||
onClick={() => onClick?.(first)}
|
||||
>
|
||||
{annotations.length > 1 && <span className={styles.count}>{annotations.length}</span>}
|
||||
</button>
|
||||
<Tooltip placement="top">
|
||||
<Column gap="3" className={styles.note}>
|
||||
{annotations.map(annotation => (
|
||||
<Column key={annotation.id} gap="1">
|
||||
<Text size="sm" weight="bold">
|
||||
{formatAnnotationDate(annotation, locale)}
|
||||
</Text>
|
||||
<Text size="sm">{annotation.label}</Text>
|
||||
</Column>
|
||||
))}
|
||||
</Column>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export * from './queries/useWebsiteMetricsQuery';
|
||||
export * from './queries/useWebsitePageviewsQuery';
|
||||
export * from './queries/useWebsiteListChartsQuery';
|
||||
export * from './queries/useWebsiteQuery';
|
||||
export * from './queries/useWebsiteAnnotationsQuery';
|
||||
export * from './queries/useWebsiteSegmentQuery';
|
||||
export * from './queries/useWebsiteSegmentsQuery';
|
||||
export * from './queries/useWebsiteSessionQuery';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
|
||||
export function useWebsiteAnnotationsQuery(
|
||||
websiteId: string,
|
||||
params?: Record<string, string | number>,
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { modified } = useModified(`annotations`);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['website:annotations', { websiteId, modified, ...params }],
|
||||
queryFn: () => get(`/websites/${websiteId}/annotations`, params),
|
||||
enabled: !!websiteId,
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -336,6 +336,9 @@ export const labels: Record<string, string> = {
|
||||
addBoard: 'label.add-board',
|
||||
selectComponent: 'label.select-component',
|
||||
addLink: 'label.add-link',
|
||||
addNote: 'label.add-note',
|
||||
notes: 'label.notes',
|
||||
note: 'label.note',
|
||||
addPixel: 'label.add-pixel',
|
||||
maximize: 'label.maximize',
|
||||
remaining: 'label.remaining',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Returns the `date` URL parameter value that filters the website view
|
||||
* to the day (all-day annotation) or hour (timed annotation) of an annotation.
|
||||
*/
|
||||
export function getAnnotationDateRangeValue(date: Date, allDay: boolean) {
|
||||
const [startDate, endDate] = allDay
|
||||
? [startOfDay(date), endOfDay(date)]
|
||||
: [startOfHour(date), endOfHour(date)];
|
||||
|
||||
return `range:${startDate.getTime()}:${endDate.getTime()}`;
|
||||
}
|
||||
@@ -78,6 +78,12 @@ export const replayParams = {
|
||||
minDuration: z.coerce.number().int().nonnegative().optional(),
|
||||
};
|
||||
|
||||
export const annotationSchema = z.object({
|
||||
date: z.coerce.date(),
|
||||
allDay: z.boolean().optional().default(true),
|
||||
note: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const pagingParams = {
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
pageSize: z.coerce.number().int().positive().optional(),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
|
||||
export async function getAnnotation(annotationId: string) {
|
||||
return prisma.client.annotation.findUnique({
|
||||
where: {
|
||||
id: annotationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWebsiteAnnotation(websiteId: string, annotationId: string) {
|
||||
return prisma.client.annotation.findFirst({
|
||||
where: { id: annotationId, websiteId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWebsiteAnnotations(
|
||||
websiteId: string,
|
||||
filters: QueryFilters & { startDate?: Date; endDate?: Date } = {},
|
||||
) {
|
||||
const { search, startDate, endDate } = filters;
|
||||
const { getSearchParameters, pagedQuery } = prisma;
|
||||
|
||||
const where: Prisma.AnnotationWhereInput = {
|
||||
websiteId,
|
||||
...(startDate &&
|
||||
endDate && {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
}),
|
||||
...getSearchParameters(search, [
|
||||
{
|
||||
note: 'contains',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
return pagedQuery(
|
||||
'annotation',
|
||||
{
|
||||
where,
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
},
|
||||
filters,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAnnotation(data: Prisma.AnnotationUncheckedCreateInput) {
|
||||
return prisma.client.annotation.create({ data });
|
||||
}
|
||||
|
||||
export async function updateAnnotation(annotationId: string, data: Prisma.AnnotationUpdateInput) {
|
||||
return prisma.client.annotation.update({ where: { id: annotationId }, data });
|
||||
}
|
||||
|
||||
export async function deleteAnnotation(annotationId: string) {
|
||||
return prisma.client.annotation.delete({ where: { id: annotationId } });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './annotation';
|
||||
export * from './board';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
|
||||
@@ -83,6 +83,11 @@ function createDeleteTx(calls: string[]) {
|
||||
calls.push('segment');
|
||||
}),
|
||||
},
|
||||
annotation: {
|
||||
deleteMany: vi.fn(async () => {
|
||||
calls.push('annotation');
|
||||
}),
|
||||
},
|
||||
share: {
|
||||
deleteMany: vi.fn(async () => {
|
||||
calls.push('share');
|
||||
@@ -149,6 +154,7 @@ describe('website delete dependencies', () => {
|
||||
'session',
|
||||
'report',
|
||||
'segment',
|
||||
'annotation',
|
||||
'share',
|
||||
'websiteDelete',
|
||||
]);
|
||||
|
||||
@@ -237,6 +237,10 @@ export async function deleteWebsite(websiteId: string) {
|
||||
where: { websiteId },
|
||||
});
|
||||
|
||||
await tx.annotation.deleteMany({
|
||||
where: { websiteId },
|
||||
});
|
||||
|
||||
await tx.share.deleteMany({
|
||||
where: { entityId: websiteId },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user