Annotations workflow, UI/UX polish
This commit is contained in:
1 parent
26e236c95c
commit
336fbcb3c5
18 files changed
+463
-87
No files matched your search
@@ -443,6 +443,7 @@
|
||||
"incorrect-username-password": "Incorrect username/password.",
|
||||
"invalid-domain": "Invalid domain",
|
||||
"invalid-value": "Invalid value",
|
||||
"max-length": "Maximum length of {n} characters",
|
||||
"min-password-length": "Minimum length of {n} characters",
|
||||
"new-version-available": "A new version of Umami {version} is available!",
|
||||
"no-data-available": "No data available.",
|
||||
|
||||
@@ -452,6 +452,7 @@
|
||||
"incorrect-username-password": "Incorrect username and/or password.",
|
||||
"invalid-domain": "Invalid domain. Do not include http/https.",
|
||||
"invalid-value": "Invalid value",
|
||||
"max-length": "Maximum length of {n} characters",
|
||||
"min-password-length": "Minimum length of {n} characters",
|
||||
"new-version-available": "A new version of Umami {version} is available!",
|
||||
"no-data-available": "No data available.",
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TopNav() {
|
||||
paddingRight="5"
|
||||
width="100%"
|
||||
zIndex={100}
|
||||
backgroundColor="surface-raised"
|
||||
>
|
||||
<Row alignItems="center">
|
||||
<TeamsButton />
|
||||
@@ -101,6 +102,17 @@ export function TopNav() {
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 16,
|
||||
background: 'linear-gradient(to bottom, var(--zen-surface-raised), transparent)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ChartAnnotation } from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
@@ -10,30 +11,37 @@ import {
|
||||
} from '@/components/hooks';
|
||||
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
|
||||
import { PageviewsChart } from '@/components/metrics/PageviewsChart';
|
||||
import { getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
import { type AnnotationRange, getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
import { DATE_FUNCTIONS } from '@/lib/date';
|
||||
|
||||
export function WebsiteChart({
|
||||
websiteId,
|
||||
compareMode,
|
||||
showAnnotations,
|
||||
onAnnotationMoreClick,
|
||||
}: {
|
||||
websiteId: string;
|
||||
compareMode?: boolean;
|
||||
showAnnotations?: boolean;
|
||||
onAnnotationMoreClick?: (range: AnnotationRange) => void;
|
||||
}) {
|
||||
const { timezone, localFromUtc } = useTimezone();
|
||||
const { timezone, localFromUtc, localToUtc } = 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: annotationData } = useWebsiteAnnotationsQuery(
|
||||
websiteId,
|
||||
{ startAt, endAt, pageSize: 1000 },
|
||||
{ enabled: !!showAnnotations && !!websiteId },
|
||||
);
|
||||
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
|
||||
websiteId,
|
||||
compare: compareMode ? dateCompare?.compare : undefined,
|
||||
});
|
||||
const { pageviews, sessions, compare } = (data || {}) as any;
|
||||
const canDrillIntoAnnotation =
|
||||
unit !== 'hour' && unit !== 'minute' && !isSameDay(startDate, endDate);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) {
|
||||
@@ -61,19 +69,47 @@ 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 isSubDayUnit = unit === 'hour' || unit === 'minute';
|
||||
|
||||
return (annotationData?.data || [])
|
||||
.filter(({ allDay }) => !isSubDayUnit || allDay === false)
|
||||
.map(({ id, date, note, allDay }) => {
|
||||
const annotationDate = localFromUtc(new Date(date));
|
||||
|
||||
return {
|
||||
id,
|
||||
date: annotationDate,
|
||||
markerDate: DATE_FUNCTIONS[unit].start(annotationDate),
|
||||
label: note,
|
||||
allDay,
|
||||
isClickable: canDrillIntoAnnotation,
|
||||
isGroupClickable: unit === 'month',
|
||||
};
|
||||
});
|
||||
}, [annotationData, timezone, unit, canDrillIntoAnnotation]);
|
||||
|
||||
const handleAnnotationClick = useCallback(
|
||||
(annotation: ChartAnnotation) => {
|
||||
(annotations: ChartAnnotation[]) => {
|
||||
const [annotation] = annotations;
|
||||
const hasOneDate = annotations.every(item => isSameDay(item.date, annotation.date));
|
||||
|
||||
if (hasOneDate) {
|
||||
router.push(
|
||||
updateParams({
|
||||
date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false),
|
||||
offset: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerDate = annotation.markerDate || annotation.date;
|
||||
const startDate = DATE_FUNCTIONS.month.start(markerDate);
|
||||
const endDate = DATE_FUNCTIONS.month.end(markerDate);
|
||||
|
||||
router.push(
|
||||
updateParams({
|
||||
date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false),
|
||||
date: `range:${startDate.getTime()}:${endDate.getTime()}`,
|
||||
offset: undefined,
|
||||
}),
|
||||
);
|
||||
@@ -81,6 +117,19 @@ export function WebsiteChart({
|
||||
[router, updateParams],
|
||||
);
|
||||
|
||||
const handleAnnotationMoreClick = useCallback(
|
||||
(annotations: ChartAnnotation[]) => {
|
||||
const markerDate = annotations[0].markerDate || annotations[0].date;
|
||||
const { start, end } = DATE_FUNCTIONS[unit];
|
||||
|
||||
onAnnotationMoreClick?.({
|
||||
startAt: +localToUtc(start(markerDate)),
|
||||
endAt: +localToUtc(end(markerDate)),
|
||||
});
|
||||
},
|
||||
[localToUtc, onAnnotationMoreClick, unit],
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
|
||||
<PageviewsChart
|
||||
@@ -91,6 +140,7 @@ export function WebsiteChart({
|
||||
unit={unit}
|
||||
annotations={annotations}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
onAnnotationMoreClick={onAnnotationMoreClick ? handleAnnotationMoreClick : undefined}
|
||||
/>
|
||||
</LoadingPanel>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
'use client';
|
||||
import { Column, Row } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import type { AnnotationRange } from '@/lib/annotations';
|
||||
import { UnitFilter } from '@/components/input/UnitFilter';
|
||||
import { AnnotationsButton } from './annotations/AnnotationsButton';
|
||||
import { AnnotationsModal } from './annotations/AnnotationsModal';
|
||||
import { WebsiteChart } from './WebsiteChart';
|
||||
import { WebsiteControls } from './WebsiteControls';
|
||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||
import { WebsitePanels } from './WebsitePanels';
|
||||
|
||||
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||
const [annotationRange, setAnnotationRange] = useState<AnnotationRange | null>(null);
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowBounceFilter={true} />
|
||||
@@ -18,13 +24,36 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||
<Row justifyContent="end">
|
||||
<UnitFilter />
|
||||
</Row>
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
<WebsiteChart
|
||||
websiteId={websiteId}
|
||||
showAnnotations
|
||||
onAnnotationMoreClick={setAnnotationRange}
|
||||
/>
|
||||
<Row justifyContent="end">
|
||||
<AnnotationsButton websiteId={websiteId} />
|
||||
</Row>
|
||||
</Panel>
|
||||
<WebsitePanels websiteId={websiteId} />
|
||||
<ExpandedViewModal websiteId={websiteId} />
|
||||
<DialogButton
|
||||
isOpen={!!annotationRange}
|
||||
onOpenChange={isOpen => !isOpen && setAnnotationRange(null)}
|
||||
title={null}
|
||||
width="800px"
|
||||
>
|
||||
{({ close }) =>
|
||||
annotationRange && (
|
||||
<AnnotationsModal
|
||||
websiteId={websiteId}
|
||||
range={annotationRange}
|
||||
onClose={() => {
|
||||
close();
|
||||
setAnnotationRange(null);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</DialogButton>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,10 @@ 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();
|
||||
@@ -33,10 +31,7 @@ export function AnnotationDeleteButton({
|
||||
<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>,
|
||||
})}
|
||||
message={t(messages.confirmDelete, { target: t(labels.note) })}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import {
|
||||
Button,
|
||||
Calendar as ZenCalendar,
|
||||
Column,
|
||||
DatePicker,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
Icon,
|
||||
Label,
|
||||
Popover,
|
||||
Text,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { startOfDay } from 'date-fns';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useMessages, useTimezone, useUpdateQuery } from '@/components/hooks';
|
||||
import { Calendar } from '@/components/icons';
|
||||
import { formatDate } from '@/lib/date';
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
@@ -21,6 +27,51 @@ export interface Annotation {
|
||||
note: string;
|
||||
}
|
||||
|
||||
function AnnotationDatePicker({
|
||||
date,
|
||||
label,
|
||||
locale,
|
||||
onChange,
|
||||
}: {
|
||||
date: Date;
|
||||
label: string;
|
||||
locale: string;
|
||||
onChange: (date: Date) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleChange = (value: Date) => {
|
||||
onChange(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Column gap="1">
|
||||
<Label>{label}</Label>
|
||||
<DialogTrigger overlayType="popover" isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={label}
|
||||
style={{ width: 'fit-content', cursor: 'pointer', flexDirection: 'row-reverse' }}
|
||||
>
|
||||
<Icon size="sm">
|
||||
<Calendar />
|
||||
</Icon>
|
||||
{formatDate(date, 'PP', locale)}
|
||||
</Button>
|
||||
<Popover
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="min-w-[340px] bg-surface-overlay border border-edge-muted rounded-lg shadow-lg p-4"
|
||||
>
|
||||
<ZenCalendar value={date} onChange={handleChange} />
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnnotationEditForm({
|
||||
annotation,
|
||||
websiteId,
|
||||
@@ -70,29 +121,51 @@ export function AnnotationEditForm({
|
||||
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>
|
||||
{({ watch }) => {
|
||||
const noteLength = (watch('note') || '').length;
|
||||
const isOverLimit = noteLength > 500;
|
||||
|
||||
<FormField
|
||||
name="note"
|
||||
label={t(labels.note)}
|
||||
rules={{ required: t(labels.required), maxLength: 500 }}
|
||||
>
|
||||
<TextField asTextArea autoFocus />
|
||||
</FormField>
|
||||
return (
|
||||
<Column gap>
|
||||
<AnnotationDatePicker
|
||||
label={t(labels.date)}
|
||||
date={date}
|
||||
locale={locale}
|
||||
onChange={setDate}
|
||||
/>
|
||||
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Column>
|
||||
<Column gap="1">
|
||||
<FormField
|
||||
name="note"
|
||||
label={t(labels.note)}
|
||||
rules={{
|
||||
required: t(labels.required),
|
||||
maxLength: { value: 500, message: t(messages.maxLength, { n: '500' }) },
|
||||
}}
|
||||
>
|
||||
<TextField asTextArea autoFocus style={{ minHeight: 128 }} />
|
||||
</FormField>
|
||||
<Text
|
||||
size="sm"
|
||||
color="muted"
|
||||
align="right"
|
||||
style={isOverLimit ? { color: 'var(--zen-status-error)' } : undefined}
|
||||
>
|
||||
{noteLength} / 500
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||
{t(labels.save)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
</Column>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useMessages, useShare } from '@/components/hooks';
|
||||
import { useDateParameters, useMessages } 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;
|
||||
}
|
||||
const { startAt, endAt } = useDateParameters();
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
@@ -19,7 +15,9 @@ export function AnnotationsButton({ websiteId }: { websiteId: string }) {
|
||||
variant="quiet"
|
||||
width="800px"
|
||||
>
|
||||
{({ close }) => <AnnotationsModal websiteId={websiteId} onClose={close} />}
|
||||
{({ close }) => (
|
||||
<AnnotationsModal websiteId={websiteId} range={{ startAt, endAt }} onClose={close} />
|
||||
)}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@/test/render';
|
||||
import { AnnotationsModal } from './AnnotationsModal';
|
||||
|
||||
const mockUseWebsiteAnnotationsQuery = vi.fn();
|
||||
|
||||
vi.mock('@/components/charts/ChartAnnotationMarkers', () => ({
|
||||
formatAnnotationDate: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('@/components/hooks', () => ({
|
||||
useLocale: () => ({ locale: 'en-US' }),
|
||||
useMessages: () => ({
|
||||
t: (value: string) => value,
|
||||
labels: {
|
||||
addNote: 'Add note',
|
||||
all: 'All',
|
||||
current: 'Current',
|
||||
date: 'Date',
|
||||
dateRange: 'date range',
|
||||
note: 'Note',
|
||||
notes: 'Notes',
|
||||
},
|
||||
messages: {
|
||||
noDataAvailable: 'No data available',
|
||||
},
|
||||
}),
|
||||
useMobile: () => ({ isMobile: false }),
|
||||
useNavigation: () => ({ router: { push: vi.fn() }, updateParams: vi.fn() }),
|
||||
useShare: () => null,
|
||||
useTimezone: () => ({ localFromUtc: (date: Date) => date }),
|
||||
useWebsiteAnnotationsQuery: (...args: unknown[]) => mockUseWebsiteAnnotationsQuery(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/input/FilterButtons', () => ({
|
||||
FilterButtons: ({ onChange }: { onChange: (value: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange('all')}>
|
||||
All notes
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseWebsiteAnnotationsQuery.mockClear();
|
||||
mockUseWebsiteAnnotationsQuery.mockReturnValue({ data: { data: [], count: 0 }, isLoading: false });
|
||||
});
|
||||
|
||||
test('defaults to the supplied date range and can show all notes', async () => {
|
||||
const { user } = render(
|
||||
<AnnotationsModal websiteId="website-1" range={{ startAt: 1000, endAt: 2000 }} />,
|
||||
);
|
||||
|
||||
expect(mockUseWebsiteAnnotationsQuery).toHaveBeenLastCalledWith('website-1', {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
startAt: 1000,
|
||||
endAt: 2000,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'All notes' }));
|
||||
|
||||
expect(mockUseWebsiteAnnotationsQuery).toHaveBeenLastCalledWith('website-1', {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
});
|
||||
@@ -13,44 +13,61 @@ 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 { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import {
|
||||
useLocale,
|
||||
useMessages,
|
||||
useMobile,
|
||||
useNavigation,
|
||||
useShare,
|
||||
useTimezone,
|
||||
useWebsiteAnnotationsQuery,
|
||||
} from '@/components/hooks';
|
||||
import { Edit, Plus } from '@/components/icons';
|
||||
import { getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
import { type AnnotationRange, getAnnotationDateRangeValue } from '@/lib/annotations';
|
||||
import { AnnotationDeleteButton } from './AnnotationDeleteButton';
|
||||
import { type Annotation, AnnotationEditForm } from './AnnotationEditForm';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const RANGE_FILTER = 'range';
|
||||
const ALL_FILTER = 'all';
|
||||
|
||||
type View = { mode: 'list' } | { mode: 'add' } | { mode: 'edit'; annotation: Annotation };
|
||||
|
||||
export function AnnotationsModal({
|
||||
websiteId,
|
||||
range,
|
||||
onClose,
|
||||
}: {
|
||||
websiteId: string;
|
||||
range: AnnotationRange;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { localFromUtc } = useTimezone();
|
||||
const { isMobile } = useMobile();
|
||||
const share = useShare();
|
||||
const { router, updateParams } = useNavigation();
|
||||
const [view, setView] = useState<View>({ mode: 'list' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [filter, setFilter] = useState(RANGE_FILTER);
|
||||
const { data, isLoading } = useWebsiteAnnotationsQuery(websiteId, {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
...(filter === RANGE_FILTER && range),
|
||||
});
|
||||
|
||||
const showList = () => setView({ mode: 'list' });
|
||||
|
||||
const toLocalDate = (annotation: Annotation) => localFromUtc(new Date(annotation.date));
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSelect = (annotation: Annotation) => {
|
||||
router.push(
|
||||
updateParams({
|
||||
@@ -85,15 +102,25 @@ export function AnnotationsModal({
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Heading size="xl">{t(labels.notes)}</Heading>
|
||||
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
|
||||
<Plus />
|
||||
{t(labels.addNote)}
|
||||
</Button>
|
||||
{!share && (
|
||||
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
|
||||
<Plus />
|
||||
{t(labels.addNote)}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<FilterButtons
|
||||
value={filter}
|
||||
onChange={handleFilterChange}
|
||||
items={[
|
||||
{ id: RANGE_FILTER, label: `${t(labels.current)} ${t(labels.dateRange)}` },
|
||||
{ id: ALL_FILTER, label: `${t(labels.all)} ${t(labels.notes)}` },
|
||||
]}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<DataTable data={rows}>
|
||||
<DataTable data={rows} displayMode={isMobile ? 'cards' : 'table'}>
|
||||
<DataColumn id="date" label={t(labels.date)} width="200px">
|
||||
{(row: Annotation) => (
|
||||
<Button onPress={() => handleSelect(row)}>
|
||||
@@ -107,25 +134,31 @@ export function AnnotationsModal({
|
||||
<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>
|
||||
{!share && (
|
||||
<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} />
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
)}
|
||||
</DataTable>
|
||||
)}
|
||||
{data?.count > PAGE_SIZE && (
|
||||
<Pager page={page} pageSize={PAGE_SIZE} count={data.count} onPageChange={setPage} />
|
||||
)}
|
||||
{onClose && (
|
||||
<Row justifyContent="flex-end">
|
||||
<Button onPress={onClose}>{t(labels.close)}</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ChartAnnotationMarkers,
|
||||
} from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { Legend } from '@/components/metrics/Legend';
|
||||
import { getChartBucketIndex } from '@/lib/charts';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
|
||||
ChartJS.defaults.font.family = 'Inter';
|
||||
@@ -26,7 +27,8 @@ export interface ChartProps extends BoxProps {
|
||||
hiddenLabels?: Set<string>;
|
||||
onLegendClick?: (label: string, willBeHidden: boolean) => void;
|
||||
annotations?: ChartAnnotation[];
|
||||
onAnnotationClick?: (annotation: ChartAnnotation) => void;
|
||||
onAnnotationClick?: (annotations: ChartAnnotation[]) => void;
|
||||
onAnnotationMoreClick?: (annotations: ChartAnnotation[]) => void;
|
||||
}
|
||||
|
||||
function isSameMarkers(a: AnnotationMarker[], b: AnnotationMarker[]) {
|
||||
@@ -53,6 +55,7 @@ export function Chart({
|
||||
onLegendClick,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
onAnnotationMoreClick,
|
||||
...props
|
||||
}: ChartProps) {
|
||||
const canvas = useRef(null);
|
||||
@@ -79,7 +82,13 @@ export function Chart({
|
||||
const groups = new Map<number, AnnotationMarker>();
|
||||
|
||||
for (const annotation of list) {
|
||||
const x = Math.round(scales.x.getPixelForValue(annotation.date.getTime()));
|
||||
const markerDate = annotation.markerDate || annotation.date;
|
||||
const bucketIndex = getChartBucketIndex(
|
||||
instance.data.datasets[0]?.data || [],
|
||||
markerDate,
|
||||
);
|
||||
const bucket = instance.getDatasetMeta(0)?.data[bucketIndex];
|
||||
const x = Math.round(bucket?.x ?? scales.x.getPixelForValue(markerDate.getTime()));
|
||||
|
||||
if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) {
|
||||
continue;
|
||||
@@ -217,7 +226,11 @@ export function Chart({
|
||||
<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} />
|
||||
<ChartAnnotationMarkers
|
||||
markers={markers}
|
||||
onClick={onAnnotationClick}
|
||||
onMoreClick={onAnnotationMoreClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -37,6 +37,34 @@
|
||||
|
||||
.note {
|
||||
max-width: 300px;
|
||||
max-height: min(280px, 50vh);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.noteFull {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.noteText {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.moreButton {
|
||||
align-self: center;
|
||||
min-height: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.moreButton:hover,
|
||||
.moreButton:focus-visible {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from '@/components/hooks';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { useLocale, useMessages } from '@/components/hooks';
|
||||
import { Maximize } from '@/components/icons';
|
||||
import { getThemeColors } from '@/lib/colors';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import { Button, Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { useMemo } from 'react';
|
||||
import styles from './ChartAnnotationMarkers.module.css';
|
||||
|
||||
const MAX_TOOLTIP_ANNOTATIONS = 3;
|
||||
|
||||
export interface ChartAnnotation {
|
||||
id: string;
|
||||
date: Date;
|
||||
markerDate?: Date;
|
||||
label: string;
|
||||
allDay?: boolean;
|
||||
isClickable?: boolean;
|
||||
isGroupClickable?: boolean;
|
||||
}
|
||||
|
||||
export interface AnnotationMarker {
|
||||
@@ -25,40 +33,80 @@ export function formatAnnotationDate(annotation: ChartAnnotation, locale: string
|
||||
export function ChartAnnotationMarkers({
|
||||
markers,
|
||||
onClick,
|
||||
onMoreClick,
|
||||
}: {
|
||||
markers: AnnotationMarker[];
|
||||
onClick?: (annotation: ChartAnnotation) => void;
|
||||
onClick?: (annotations: ChartAnnotation[]) => void;
|
||||
onMoreClick?: (annotations: ChartAnnotation[]) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
const { t, labels } = useMessages();
|
||||
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ annotations, x, y }) => {
|
||||
const [first] = annotations;
|
||||
const hasOneDate = annotations.every(annotation => isSameDay(annotation.date, first.date));
|
||||
const isClickable =
|
||||
!!onClick &&
|
||||
(hasOneDate
|
||||
? first.isClickable !== false
|
||||
: annotations.every(annotation => annotation.isGroupClickable === true));
|
||||
const visibleAnnotations = annotations.slice(0, MAX_TOOLTIP_ANNOTATIONS);
|
||||
|
||||
return (
|
||||
<TooltipTrigger key={first.id} delay={0}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{ left: x, top: y, backgroundColor: colors.chart.views.hoverBorderColor }}
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
backgroundColor: colors.chart.views.hoverBorderColor,
|
||||
}}
|
||||
aria-label={annotations.map(({ label }) => label).join(', ')}
|
||||
onClick={() => onClick?.(first)}
|
||||
onClick={isClickable ? () => onClick?.(annotations) : undefined}
|
||||
>
|
||||
{annotations.length > 1 && <span className={styles.count}>{annotations.length}</span>}
|
||||
</button>
|
||||
<Tooltip placement="top">
|
||||
<Column gap="3" className={styles.note}>
|
||||
{annotations.map(annotation => (
|
||||
<Column
|
||||
gap="3"
|
||||
padding="1"
|
||||
borderRadius="md"
|
||||
className={styles.note}
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.2)', color: 'white' }}
|
||||
>
|
||||
{visibleAnnotations.map(annotation => (
|
||||
<Column key={annotation.id} gap="1">
|
||||
<Text size="sm" weight="bold">
|
||||
{formatAnnotationDate(annotation, locale)}
|
||||
</Text>
|
||||
<Text size="sm">{annotation.label}</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
className={annotations.length === 1 ? styles.noteFull : styles.noteText}
|
||||
>
|
||||
{annotation.label}
|
||||
</Text>
|
||||
</Column>
|
||||
))}
|
||||
{annotations.length > 1 &&
|
||||
(onMoreClick ? (
|
||||
<Button
|
||||
variant="quiet"
|
||||
className={styles.moreButton}
|
||||
onPress={() => onMoreClick(annotations)}
|
||||
>
|
||||
<IconLabel icon={<Maximize />}>{t(labels.view)}</IconLabel>
|
||||
</Button>
|
||||
) : (
|
||||
<IconLabel icon={<Maximize />} style={{ opacity: 0.8 }}>
|
||||
{t(labels.view)}
|
||||
</IconLabel>
|
||||
))}
|
||||
</Column>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export interface ConfirmationFormProps {
|
||||
message: ReactNode;
|
||||
message?: ReactNode;
|
||||
buttonLabel?: ReactNode;
|
||||
buttonVariant?: 'primary' | 'quiet' | 'danger';
|
||||
isLoading?: boolean;
|
||||
@@ -25,7 +25,7 @@ export function ConfirmationForm({
|
||||
|
||||
return (
|
||||
<Form onSubmit={onConfirm} error={getErrorMessage(error)}>
|
||||
<Box marginY="4">{message}</Box>
|
||||
{message && <Box marginY="4">{message}</Box>}
|
||||
<FormButtons>
|
||||
<Button onPress={onClose}>{t(labels.cancel)}</Button>
|
||||
<FormSubmitButton
|
||||
|
||||
@@ -449,6 +449,7 @@ export const messages: Record<string, string> = {
|
||||
confirmDelete: 'message.confirm-delete',
|
||||
confirmRemove: 'message.confirm-remove',
|
||||
confirmLeave: 'message.confirm-leave',
|
||||
maxLength: 'message.max-length',
|
||||
minPasswordLength: 'message.min-password-length',
|
||||
noTeams: 'message.no-teams',
|
||||
shareUrl: 'message.share-url',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns';
|
||||
|
||||
export interface AnnotationRange {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `date` URL parameter value that filters the website view
|
||||
* to the day (all-day annotation) or hour (timed annotation) of an annotation.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderNumberLabels } from './charts';
|
||||
import { getChartBucketIndex, renderNumberLabels } from './charts';
|
||||
|
||||
// test for renderNumberLabels
|
||||
|
||||
@@ -38,3 +38,11 @@ describe('renderNumberLabels', () => {
|
||||
expect(renderNumberLabels(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChartBucketIndex', () => {
|
||||
test('places an annotation in its monthly data bucket', () => {
|
||||
const data = [{ x: '2026-08' }, { x: '2026-09' }];
|
||||
|
||||
expect(getChartBucketIndex(data, new Date('2026-08-27T07:00:00.000Z'))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,18 @@ export function renderDateLabels(unit: string, locale: string) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getChartBucketIndex(data: any[], date: Date) {
|
||||
let index = -1;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const timestamp = new Date(data[i]?.x ?? data[i]).getTime();
|
||||
|
||||
if (!Number.isFinite(timestamp)) continue;
|
||||
if (timestamp > date.getTime()) break;
|
||||
|
||||
index = i;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
Reference in new issue
Block a user