From 44a252f6d76663c9afb43aa0f2cedcb75134fd94 Mon Sep 17 00:00:00 2001
From: Artur Pata
Date: Thu, 2 Jul 2026 18:40:30 +0300
Subject: [PATCH 1/9] Add Annotations feature
---
assets/css/app.css | 4 +
.../annotations/annotation-list-items.tsx | 60 +++
.../annotations/annotations-modals.tsx | 377 ++++++++++++++++++
.../dashboard/annotations/annotations.test.ts | 330 +++++++++++++++
.../js/dashboard/annotations/annotations.ts | 222 +++++++++++
.../annotations/hover-annotations-list.tsx | 36 ++
.../interactive-annotations-list.tsx | 75 ++++
.../routeless-annotations-modals.tsx | 183 +++++++++
assets/js/dashboard/components/button.tsx | 5 +-
.../js/dashboard/components/form-elements.tsx | 143 ++++++-
.../js/dashboard/components/graph-tooltip.tsx | 81 ++--
assets/js/dashboard/components/graph.tsx | 94 ++++-
assets/js/dashboard/components/icons.tsx | 17 +
assets/js/dashboard/nav-menu/filters-bar.tsx | 2 +-
.../nav-menu/segments/segment-menu.tsx | 6 +-
.../navigation/routeless-modals-context.tsx | 3 +-
assets/js/dashboard/router.tsx | 2 +
.../segments/routeless-segment-modals.tsx | 11 +-
assets/js/dashboard/site-context.test.tsx | 2 +
assets/js/dashboard/site-context.tsx | 2 +
.../js/dashboard/stats/graph/main-graph.tsx | 352 ++++++++++++----
.../dashboard/stats/graph/visitor-graph.tsx | 8 +-
assets/js/dashboard/util/date.js | 10 +
assets/js/dashboard/util/date.test.ts | 7 +
assets/test-utils/app-context-providers.tsx | 1 +
e2e/tests/dashboard/annotations.spec.ts | 265 ++++++++++++
e2e/tests/dashboard/main-graph.spec.ts | 17 +-
27 files changed, 2174 insertions(+), 141 deletions(-)
create mode 100644 assets/js/dashboard/annotations/annotation-list-items.tsx
create mode 100644 assets/js/dashboard/annotations/annotations-modals.tsx
create mode 100644 assets/js/dashboard/annotations/annotations.test.ts
create mode 100644 assets/js/dashboard/annotations/annotations.ts
create mode 100644 assets/js/dashboard/annotations/hover-annotations-list.tsx
create mode 100644 assets/js/dashboard/annotations/interactive-annotations-list.tsx
create mode 100644 assets/js/dashboard/annotations/routeless-annotations-modals.tsx
create mode 100644 e2e/tests/dashboard/annotations.spec.ts
diff --git a/assets/css/app.css b/assets/css/app.css
index 33a1f4faa076..4e3cc348a851 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -63,6 +63,10 @@
disabled:cursor-not-allowed;
}
+ .btn-xs {
+ @apply px-2 py-1;
+ }
+
.btn-sm {
@apply px-3 py-2;
}
diff --git a/assets/js/dashboard/annotations/annotation-list-items.tsx b/assets/js/dashboard/annotations/annotation-list-items.tsx
new file mode 100644
index 000000000000..cb456cf7a618
--- /dev/null
+++ b/assets/js/dashboard/annotations/annotation-list-items.tsx
@@ -0,0 +1,60 @@
+import React, { ReactNode } from 'react'
+import classNames from 'classnames'
+import {
+ Annotation,
+ getAnnotationAttribution,
+ getAttributionDateLabel
+} from './annotations'
+
+export const AnnotationsListContainer = ({
+ children
+}: {
+ children: ReactNode
+}) => (
+
+ {children}
+
+)
+
+const VerticalBar = () => (
+
+)
+
+export const AnnotationItemRow = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+)
+
+export const AnnotationAttributionLine = ({
+ annotation
+}: {
+ annotation: Annotation
+}) => (
+
+
+ {getAnnotationAttribution(annotation)}
+
+
+ {` • ${getAttributionDateLabel(annotation)}`}
+
+
+)
+
+export const AnnotationNote = ({
+ note,
+ clamp
+}: {
+ note: string
+ clamp?: boolean
+}) => (
+
+ {note}
+
+)
diff --git a/assets/js/dashboard/annotations/annotations-modals.tsx b/assets/js/dashboard/annotations/annotations-modals.tsx
new file mode 100644
index 000000000000..b358941be2f5
--- /dev/null
+++ b/assets/js/dashboard/annotations/annotations-modals.tsx
@@ -0,0 +1,377 @@
+import React, { ReactNode, useState } from 'react'
+import {
+ Annotation,
+ ANNOTATION_TYPE_LABELS,
+ AnnotationGranularity,
+ AnnotationPayload,
+ AnnotationType
+} from './annotations'
+import { MutationStatus } from '@tanstack/react-query'
+import { ApiError } from '../api'
+import { ErrorPanel } from '../components/error-panel'
+import {
+ ModalLayout,
+ ModalFooter,
+ SaveButton
+} from '../components/modal-layout'
+import {
+ LabeledTextarea,
+ TypeSelector,
+ TypeDisabledMessage,
+ getOptionDisabledMessage,
+ isOverMaxLength,
+ OptionDisabledMessageType
+} from '../components/form-elements'
+import { Button } from '../components/button'
+import { Role, UserContextValue } from '../user-context'
+import {
+ formatDay,
+ formatTime,
+ is12HourClock,
+ parseUTCDate
+} from '../util/date'
+
+const formatAnnotationDatetime = (
+ datetime: string,
+ granularity: AnnotationGranularity
+): string => {
+ const date = parseUTCDate(datetime)
+ if (granularity === AnnotationGranularity.minute) {
+ const time = formatTime(date, {
+ use12HourClock: is12HourClock(),
+ includeMinutes: true
+ })
+ return `${formatDay(date)} at ${time}`
+ }
+ return formatDay(date)
+}
+
+const NOTE_RECOMMENDED_MAX_LENGTH = 250
+
+interface ApiRequestProps {
+ status: MutationStatus
+ error?: unknown
+ reset: () => void
+}
+
+interface AnnotationModalProps {
+ user: UserContextValue
+ siteAnnotationsAvailable: boolean
+ onClose: () => void
+ notePlaceholder: string
+}
+
+export const CreateAnnotationModal = ({
+ onClose,
+ onSave,
+ user,
+ siteAnnotationsAvailable,
+ notePlaceholder,
+ initialDatetime,
+ initialGranularity,
+ initialType,
+ error,
+ reset,
+ status
+}: AnnotationModalProps &
+ ApiRequestProps & {
+ initialDatetime: AnnotationPayload['datetime']
+ initialGranularity: AnnotationPayload['granularity']
+ initialType: AnnotationPayload['type']
+ } & {
+ onSave: (input: AnnotationPayload) => void
+ }) => {
+ const defaultNote = ''
+ const [note, setNote] = useState(defaultNote)
+ const [type, setType] = useState(initialType)
+
+ const granularity = initialGranularity
+ const datetime = initialDatetime
+
+ const disabledMessage =
+ type === AnnotationType.site
+ ? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
+ : null
+
+ const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)
+
+ return (
+
+
+
+
+
+ {
+ const trimmedNote = note.trim()
+ const saveableNote = trimmedNote.length
+ ? trimmedNote
+ : notePlaceholder
+
+ onSave({
+ note: saveableNote,
+ type,
+ datetime,
+ granularity
+ })
+ }}
+ />
+
+ {error !== null && (
+
+ )}
+
+ )
+}
+
+const AnnotationTypeSelector = ({
+ value,
+ onChange,
+ optionDisabledMessage
+}: {
+ value: AnnotationType
+ onChange: (value: AnnotationType) => void
+ optionDisabledMessage: OptionDisabledMessageType | null
+}) => (
+ <>
+
+ idPrefix="annotation-type"
+ value={value}
+ onChange={onChange}
+ options={[
+ {
+ type: AnnotationType.personal,
+ name: ANNOTATION_TYPE_LABELS[AnnotationType.personal],
+ description: 'Visible only to you'
+ },
+ {
+ type: AnnotationType.site,
+ name: ANNOTATION_TYPE_LABELS[AnnotationType.site],
+ description: 'Visible to others on the site'
+ }
+ ]}
+ />
+ {optionDisabledMessage !== null && (
+
+ }
+ />
+ )}
+ >
+)
+
+const AnnotationTypeDisabledMessage = ({
+ messageType
+}: {
+ messageType: OptionDisabledMessageType
+}): Exclude => {
+ switch (messageType) {
+ case 'no-permissions':
+ return "You don't have enough permissions to change note to this type"
+ case 'upgrade-subscription-yourself':
+ return (
+ <>
+ To use this note type,{' '}
+
+ please upgrade your subscription
+
+ >
+ )
+ case 'upgrade-subscription-reach-out':
+ return (
+ <>
+ To use this note type, please reach out to a team owner to upgrade
+ their subscription.
+ >
+ )
+ }
+}
+
+const canSelectSiteAnnotation = (user: UserContextValue) =>
+ [Role.admin, Role.owner, Role.editor, 'super_admin'].includes(user.role)
+
+const getAnnotationTypeDisabledMessage = ({
+ siteAnnotationsAvailable,
+ user
+}: {
+ siteAnnotationsAvailable: boolean
+ user: UserContextValue
+}): OptionDisabledMessageType | null =>
+ getOptionDisabledMessage({
+ optionAvailable: siteAnnotationsAvailable,
+ userHasOptionPermissions: canSelectSiteAnnotation(user),
+ userCanUpgradeSubscription: user.role === Role.owner
+ })
+
+export const DeleteAnnotationModal = ({
+ annotation,
+ onClose,
+ onSave,
+ status,
+ error,
+ reset
+}: {
+ onClose: () => void
+ onSave: (input: Pick) => void
+ annotation: Annotation
+} & ApiRequestProps) => {
+ const deleteDisabled = status === 'pending'
+
+ return (
+
+ Delete {ANNOTATION_TYPE_LABELS[annotation.type].toLowerCase()}
+ {` "${annotation.note}"?`}
+ >
+ }
+ onClose={onClose}
+ >
+
+
+
+
+ {error !== null && (
+
+ )}
+
+ )
+}
+
+export const UpdateAnnotationModal = ({
+ onClose,
+ onSave,
+ onDelete,
+ annotation,
+ siteAnnotationsAvailable,
+ user,
+ notePlaceholder,
+ status,
+ error,
+ reset
+}: AnnotationModalProps &
+ ApiRequestProps & {
+ onSave: (input: Pick) => void
+ onDelete?: (annotation: Annotation) => void
+ annotation: Annotation
+ }) => {
+ const [note, setNote] = useState(annotation.note)
+ const [type, setType] = useState(annotation.type)
+
+ const disabledMessage =
+ type === AnnotationType.site
+ ? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
+ : null
+
+ const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)
+
+ return (
+
+
+
+
+ {typeof onDelete === 'function' && (
+
+ )}
+
+ {
+ const trimmedNote = note.trim()
+ const saveableNote = trimmedNote.length
+ ? trimmedNote
+ : notePlaceholder
+ onSave({ id: annotation.id, note: saveableNote, type })
+ }}
+ />
+
+ {error !== null && (
+
+ )}
+
+ )
+}
diff --git a/assets/js/dashboard/annotations/annotations.test.ts b/assets/js/dashboard/annotations/annotations.test.ts
new file mode 100644
index 000000000000..ccb616fc26ee
--- /dev/null
+++ b/assets/js/dashboard/annotations/annotations.test.ts
@@ -0,0 +1,330 @@
+import {
+ AnnotationGranularity,
+ AnnotationType,
+ canAddAnnotation,
+ canEditAnnotation,
+ getAnnotationAttribution,
+ getAnnotationGranularity,
+ getAnnotationTimeLabel,
+ groupAnnotationsByTimeLabel
+} from './annotations'
+import { Interval } from '../stats/graph/intervals'
+import { Role, UserContextValue } from '../user-context'
+
+const loggedInUser = (role: Role, id: number = 42): UserContextValue => ({
+ loggedIn: true,
+ id,
+ role,
+ team: { identifier: null, hasConsolidatedView: false }
+})
+
+const publicUser: UserContextValue = {
+ loggedIn: false,
+ id: null,
+ role: Role.public,
+ team: { identifier: null, hasConsolidatedView: false }
+}
+
+describe(`${getAnnotationAttribution.name}`, () => {
+ it('returns the owner name for a site-wide annotation with an owner', () => {
+ expect(
+ getAnnotationAttribution({
+ type: AnnotationType.site,
+ owner_name: 'Alice'
+ })
+ ).toBe('Alice')
+ })
+
+ it('returns "Site-wide note" for a site annotation with a dangling owner', () => {
+ expect(
+ getAnnotationAttribution({
+ type: AnnotationType.site,
+ owner_name: null
+ })
+ ).toBe('Site-wide note')
+ })
+
+ it('returns "Personal note" for a personal annotation regardless of owner (we assume personal notes are served only to the author)', () => {
+ expect(
+ getAnnotationAttribution({
+ type: AnnotationType.personal,
+ owner_name: 'Alice'
+ })
+ ).toBe('Personal note')
+ })
+})
+
+describe(`${canAddAnnotation.name}`, () => {
+ it.each([[Role.admin], [Role.editor], [Role.owner]])(
+ 'allows adding a site annotation if the user is logged in, in role %p, and the site-annotations feature is available',
+ (role) => {
+ expect(
+ canAddAnnotation({
+ type: AnnotationType.site,
+ user: loggedInUser(role),
+ siteAnnotationsAvailable: true
+ })
+ ).toBe(true)
+ }
+ )
+
+ it.each([[Role.admin], [Role.editor], [Role.owner]])(
+ 'forbids adding a site annotation in role %p when the feature is unavailable (billing gate)',
+ (role) => {
+ expect(
+ canAddAnnotation({
+ type: AnnotationType.site,
+ user: loggedInUser(role),
+ siteAnnotationsAvailable: false
+ })
+ ).toBe(false)
+ }
+ )
+
+ it.each([[Role.viewer], [Role.billing]])(
+ 'forbids adding a site annotation in role %p even when the feature is available',
+ (role) => {
+ expect(
+ canAddAnnotation({
+ type: AnnotationType.site,
+ user: loggedInUser(role),
+ siteAnnotationsAvailable: true
+ })
+ ).toBe(false)
+ }
+ )
+
+ it.each([
+ [Role.viewer],
+ [Role.billing],
+ [Role.editor],
+ [Role.admin],
+ [Role.owner]
+ ])(
+ 'allows adding a personal annotation in role %p regardless of site-annotations availability',
+ (role) => {
+ expect(
+ canAddAnnotation({
+ type: AnnotationType.personal,
+ user: loggedInUser(role),
+ siteAnnotationsAvailable: true
+ })
+ ).toBe(true)
+ expect(
+ canAddAnnotation({
+ type: AnnotationType.personal,
+ user: loggedInUser(role),
+ siteAnnotationsAvailable: false
+ })
+ ).toBe(true)
+ }
+ )
+
+ it.each([[AnnotationType.personal], [AnnotationType.site]])(
+ 'forbids the public role from adding %s annotations',
+ (type) => {
+ expect(
+ canAddAnnotation({
+ type,
+ user: publicUser,
+ siteAnnotationsAvailable: true
+ })
+ ).toBe(false)
+ }
+ )
+})
+
+describe(`${canEditAnnotation.name}`, () => {
+ // Mirrors `can_update_one?` (and `get_one`'s personal-note filter) in
+ // lib/plausible/annotations/annotations.ex.
+
+ it.each([[Role.admin], [Role.editor], [Role.owner]])(
+ 'allows editing a site annotation if the user is logged in and in role %p',
+ (role) => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.site, owner_id: 1 },
+ user: loggedInUser(role, 1)
+ })
+ ).toBe(true)
+ }
+ )
+
+ it('allows editing site annotations authored by other users', () => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.site, owner_id: 222 },
+ user: loggedInUser(Role.owner, 111)
+ })
+ ).toBe(true)
+ })
+
+ it('allows editing a site annotation whose original author was removed (owner_id nulled)', () => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.site, owner_id: null },
+ user: loggedInUser(Role.admin)
+ })
+ ).toBe(true)
+ })
+
+ it.each([[Role.viewer], [Role.billing]])(
+ 'forbids editing site annotations in role %p',
+ (role) => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.site, owner_id: 1 },
+ user: loggedInUser(role, 1)
+ })
+ ).toBe(false)
+ }
+ )
+
+ it.each([
+ [Role.viewer],
+ [Role.billing],
+ [Role.editor],
+ [Role.admin],
+ [Role.owner]
+ ])(
+ 'allows editing a personal annotation that belongs to the user when in role %p',
+ (role) => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.personal, owner_id: 1 },
+ user: loggedInUser(role, 1)
+ })
+ ).toBe(true)
+ }
+ )
+
+ it('forbids even site owners from editing the personal annotations of other users', () => {
+ expect(
+ canEditAnnotation({
+ annotation: { type: AnnotationType.personal, owner_id: 222 },
+ user: loggedInUser(Role.owner, 111)
+ })
+ ).toBe(false)
+ })
+
+ it.each([[AnnotationType.personal], [AnnotationType.site]])(
+ 'forbids the public role from editing %s annotations',
+ (type) => {
+ expect(
+ canEditAnnotation({
+ annotation: { type, owner_id: null },
+ user: publicUser
+ })
+ ).toBe(false)
+ }
+ )
+})
+
+describe(`${getAnnotationGranularity.name}`, () => {
+ it.each<[Interval, AnnotationGranularity]>([
+ [Interval.minute, AnnotationGranularity.minute],
+ [Interval.hour, AnnotationGranularity.minute],
+ [Interval.day, AnnotationGranularity.date],
+ [Interval.week, AnnotationGranularity.date],
+ [Interval.month, AnnotationGranularity.date]
+ ])('maps interval %s to granularity %s', (interval, granularity) => {
+ expect(getAnnotationGranularity(interval)).toBe(granularity)
+ })
+})
+
+describe(`${getAnnotationTimeLabel.name}`, () => {
+ // 2025-02-26 is a Wednesday
+ const dateAnnotation = {
+ datetime: '2025-02-26',
+ granularity: AnnotationGranularity.date
+ }
+
+ it.each<[Interval, string]>([
+ [Interval.minute, '2025-02-26'],
+ [Interval.hour, '2025-02-26'],
+ [Interval.day, '2025-02-26'],
+ [Interval.week, '2025-02-24'],
+ [Interval.month, '2025-02-01']
+ ])(
+ `date-granularity annotation on ${dateAnnotation.datetime} bucketed to %s yields %s`,
+ (interval, expected) => {
+ expect(getAnnotationTimeLabel(dateAnnotation, interval)).toBe(expected)
+ }
+ )
+
+ const minuteAnnotation = {
+ datetime: '2025-02-26T10:30:00',
+ granularity: AnnotationGranularity.minute
+ }
+ it.each<[Interval, string]>([
+ [Interval.month, '2025-02-01'],
+ [Interval.week, '2025-02-24'],
+ [Interval.day, '2025-02-26'],
+ [Interval.hour, '2025-02-26 10:00:00'],
+ [Interval.minute, '2025-02-26 10:30:00']
+ ])(
+ `minute granularity annotation with datetime ${minuteAnnotation.datetime} bucketed to %s yields %s`,
+ (interval, expected) => {
+ expect(getAnnotationTimeLabel(minuteAnnotation, interval)).toBe(expected)
+ }
+ )
+})
+
+describe(`${groupAnnotationsByTimeLabel.name}`, () => {
+ const dateGranularity = AnnotationGranularity.date
+ const annotations = [
+ { id: 1, datetime: '2025-02-24 00:00:00', granularity: dateGranularity }, // Mon
+ { id: 2, datetime: '2025-02-26 00:00:00', granularity: dateGranularity }, // Wed (same week)
+ { id: 3, datetime: '2025-03-05 00:00:00', granularity: dateGranularity } // following month
+ ]
+
+ it('groups annotations by day when the interval is day', () => {
+ const grouped = groupAnnotationsByTimeLabel(annotations, Interval.day)
+
+ expect(Object.keys(grouped).sort()).toEqual([
+ '2025-02-24',
+ '2025-02-26',
+ '2025-03-05'
+ ])
+ expect(grouped['2025-02-24']!.map((a) => a.id)).toEqual([1])
+ expect(grouped['2025-02-26']!.map((a) => a.id)).toEqual([2])
+ expect(grouped['2025-03-05']!.map((a) => a.id)).toEqual([3])
+ })
+
+ it('collapses annotations from the same week into one bucket', () => {
+ const grouped = groupAnnotationsByTimeLabel(annotations, Interval.week)
+
+ expect(Object.keys(grouped).sort()).toEqual(['2025-02-24', '2025-03-03'])
+ expect(grouped['2025-02-24']!.map((a) => a.id)).toEqual([1, 2])
+ expect(grouped['2025-03-03']!.map((a) => a.id)).toEqual([3])
+ })
+
+ it('collapses annotations from the same month into one bucket', () => {
+ const grouped = groupAnnotationsByTimeLabel(annotations, Interval.month)
+
+ expect(Object.keys(grouped).sort()).toEqual(['2025-02-01', '2025-03-01'])
+ expect(grouped['2025-02-01']!.map((a) => a.id)).toEqual([1, 2])
+ expect(grouped['2025-03-01']!.map((a) => a.id)).toEqual([3])
+ })
+
+ it('preserves insertion order within a bucket', () => {
+ const sameDay = [
+ { id: 10, datetime: '2025-02-26 08:00:00', granularity: dateGranularity },
+ { id: 11, datetime: '2025-02-26 09:00:00', granularity: dateGranularity },
+ { id: 12, datetime: '2025-02-26 10:00:00', granularity: dateGranularity }
+ ]
+
+ const grouped = groupAnnotationsByTimeLabel(sameDay, Interval.day)
+
+ expect(grouped['2025-02-26']!.map((a) => a.id)).toEqual([10, 11, 12])
+ })
+
+ it('returns an empty object when there are no annotations', () => {
+ expect(
+ groupAnnotationsByTimeLabel(
+ [] as { datetime: string; granularity: AnnotationGranularity }[],
+ Interval.day
+ )
+ ).toEqual({})
+ })
+})
diff --git a/assets/js/dashboard/annotations/annotations.ts b/assets/js/dashboard/annotations/annotations.ts
new file mode 100644
index 000000000000..4ae0328a4e1b
--- /dev/null
+++ b/assets/js/dashboard/annotations/annotations.ts
@@ -0,0 +1,222 @@
+import { Interval } from '../stats/graph/intervals'
+import { Role, UserContextValue } from '../user-context'
+import {
+ formatDayShort,
+ formatTime,
+ is12HourClock,
+ parseUTCDate
+} from '../util/date'
+
+export enum AnnotationType {
+ personal = 'personal',
+ site = 'site'
+}
+
+/** This type signifies that the owner can't be shown. */
+// type AnnotationOwnershipHidden = { owner_id: null; owner_name: null }
+
+/** This type signifies that the original owner has been removed from the site. */
+type AnnotationOwnershipDangling = { owner_id: null; owner_name: null }
+
+type AnnotationOwnership =
+ | AnnotationOwnershipDangling
+ | { owner_id: number; owner_name: string }
+
+export enum AnnotationGranularity {
+ date = 'date',
+ minute = 'minute'
+}
+
+export type Annotation = {
+ datetime: string
+ granularity: AnnotationGranularity
+ type: AnnotationType
+ note: string
+
+ id: number
+ /** datetime in site timezone, example 2025-02-26 10:00:00 */
+ inserted_at: string
+ /** datetime in site timezone, example 2025-02-26 10:00:00 */
+ updated_at: string
+} & AnnotationOwnership
+
+export type AnnotationPayload = Pick<
+ Annotation,
+ 'note' | 'datetime' | 'granularity' | 'type'
+>
+
+export const ANNOTATION_TYPE_LABELS = {
+ [AnnotationType.personal]: 'Personal note',
+ [AnnotationType.site]: 'Site-wide note'
+}
+
+export const getAnnotationAttribution = (
+ annotation: Pick
+): string => {
+ if (annotation.type === AnnotationType.site && annotation.owner_name) {
+ return annotation.owner_name
+ }
+ return ANNOTATION_TYPE_LABELS[annotation.type]
+}
+
+export const getAttributionDateLabel = (
+ annotation: Pick
+): string => {
+ const date = parseUTCDate(annotation.datetime)
+ const dayLabel = formatDayShort(date)
+ if (annotation.granularity === AnnotationGranularity.minute) {
+ const time = formatTime(date, {
+ use12HourClock: is12HourClock(),
+ includeMinutes: true
+ })
+ return `${dayLabel} ${time}`
+ }
+ return dayLabel
+}
+
+/** keep in sync with Plausible.Annotations */
+const ROLES_WITH_MAYBE_SITE_ANNOTATIONS = [Role.admin, Role.editor, Role.owner]
+const ROLES_WITH_PERSONAL_ANNOTATIONS = [
+ Role.billing,
+ Role.viewer,
+ Role.admin,
+ Role.editor,
+ Role.owner
+]
+
+export function canEditAnnotation({
+ annotation,
+ user
+}: {
+ annotation: Pick
+ user: UserContextValue
+}) {
+ if (
+ annotation.type === AnnotationType.site &&
+ user.loggedIn &&
+ ROLES_WITH_MAYBE_SITE_ANNOTATIONS.includes(user.role)
+ ) {
+ return true
+ }
+
+ if (
+ annotation.type === AnnotationType.personal &&
+ user.loggedIn &&
+ ROLES_WITH_PERSONAL_ANNOTATIONS.includes(user.role) &&
+ user.id === annotation.owner_id
+ ) {
+ return true
+ }
+
+ return false
+}
+
+export function canShowAddAnnotationButton(props: {
+ user: UserContextValue
+ siteAnnotationsAvailable: boolean
+}) {
+ return (
+ canAddAnnotation({ type: AnnotationType.personal, ...props }) ||
+ canAddAnnotation({ type: AnnotationType.site, ...props })
+ )
+}
+
+export function canAddAnnotation({
+ type,
+ user,
+ siteAnnotationsAvailable
+}: {
+ type: AnnotationType
+ user: UserContextValue
+ siteAnnotationsAvailable: boolean
+}) {
+ if (
+ type === AnnotationType.site &&
+ user.loggedIn &&
+ ROLES_WITH_MAYBE_SITE_ANNOTATIONS.includes(user.role) &&
+ siteAnnotationsAvailable
+ ) {
+ return true
+ }
+
+ if (
+ type === AnnotationType.personal &&
+ user.loggedIn &&
+ ROLES_WITH_PERSONAL_ANNOTATIONS.includes(user.role)
+ ) {
+ return true
+ }
+
+ return false
+}
+
+export const getAnnotationTimeLabel = (
+ annotation: Pick,
+ interval: Interval
+): string => {
+ const dateString = annotation.datetime.substring(0, 'YYYY-MM-DD'.length)
+ switch (annotation.granularity) {
+ case AnnotationGranularity.date: {
+ switch (interval) {
+ case Interval.month:
+ // floors to closest start of month for the date
+ return parseUTCDate(dateString).startOf('month').format('YYYY-MM-DD')
+ case Interval.week:
+ // floors to closest start of week for the date
+ return parseUTCDate(dateString).startOf('week').format('YYYY-MM-DD')
+ case Interval.day:
+ case Interval.hour:
+ case Interval.minute:
+ // floors to date
+ return dateString
+ }
+ break
+ }
+ case AnnotationGranularity.minute: {
+ switch (interval) {
+ case Interval.month:
+ // floors to closest start of month for the date
+ return parseUTCDate(dateString).startOf('month').format('YYYY-MM-DD')
+ case Interval.week:
+ // floors to closest start of week for the date
+ return parseUTCDate(dateString).startOf('week').format('YYYY-MM-DD')
+ case Interval.day:
+ // floors to date
+ return dateString
+ case Interval.hour: {
+ const [dateYYYYMMDD, timeHHMMSS] = annotation.datetime.split('T')
+ // floors time to hour
+ return `${dateYYYYMMDD} ${timeHHMMSS.substring(0, 'HH'.length)}:00:00`
+ }
+ case Interval.minute:
+ return annotation.datetime.split('T').join(' ')
+ }
+ }
+ }
+}
+
+export const groupAnnotationsByTimeLabel = <
+ T extends Pick
+>(
+ annotations: T[],
+ interval: Interval
+): Record => {
+ return annotations.reduce>((acc, annotation) => {
+ const timeLabel = getAnnotationTimeLabel(annotation, interval)
+ return { ...acc, [timeLabel]: [...(acc[timeLabel] ?? []), annotation] }
+ }, {})
+}
+
+export const getAnnotationGranularity = (
+ interval: Interval
+): AnnotationGranularity => {
+ switch (interval) {
+ case Interval.minute:
+ case Interval.hour:
+ return AnnotationGranularity.minute
+ case Interval.day:
+ case Interval.week:
+ case Interval.month:
+ return AnnotationGranularity.date
+ }
+}
diff --git a/assets/js/dashboard/annotations/hover-annotations-list.tsx b/assets/js/dashboard/annotations/hover-annotations-list.tsx
new file mode 100644
index 000000000000..78154e684f99
--- /dev/null
+++ b/assets/js/dashboard/annotations/hover-annotations-list.tsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import { Annotation } from './annotations'
+import {
+ AnnotationAttributionLine,
+ AnnotationItemRow,
+ AnnotationNote,
+ AnnotationsListContainer
+} from './annotation-list-items'
+
+const MAX_PREVIEW = 2
+
+export const HoverAnnotationsList = ({
+ annotations
+}: {
+ annotations: Annotation[]
+}) => {
+ const preview = annotations.slice(0, MAX_PREVIEW)
+ const extra = annotations.length - MAX_PREVIEW
+
+ return (
+ <>
+
+ {preview.map((annotation) => (
+
+
+
+ ))}
+
+ {extra === 1 && `and 1 more note`}
+ {extra > 1 && `and ${extra} more notes`}
+ >
+ )
+}
diff --git a/assets/js/dashboard/annotations/interactive-annotations-list.tsx b/assets/js/dashboard/annotations/interactive-annotations-list.tsx
new file mode 100644
index 000000000000..6f2d42c86c32
--- /dev/null
+++ b/assets/js/dashboard/annotations/interactive-annotations-list.tsx
@@ -0,0 +1,75 @@
+import React, { ReactNode } from 'react'
+import { Annotation, canEditAnnotation } from './annotations'
+import {
+ AnnotationAttributionLine,
+ AnnotationItemRow,
+ AnnotationNote,
+ AnnotationsListContainer
+} from './annotation-list-items'
+import { useRoutelessModalsContext } from '../navigation/routeless-modals-context'
+import { useUserContext } from '../user-context'
+import { PencilIcon } from '../components/icons'
+
+const ScrollableArea = (props: { children: ReactNode }) => (
+
+ {props.children}
+
+)
+
+export const InteractiveAnnotationsList = ({
+ annotations,
+ isTouchDevice,
+ closeTooltip
+}: {
+ annotations: Annotation[]
+ isTouchDevice: boolean
+ closeTooltip: () => void
+}) => {
+ const { setModal } = useRoutelessModalsContext()
+ const user = useUserContext()
+ const openEdit = (annotation: Annotation) => {
+ closeTooltip()
+ setModal({ type: 'update-annotation', annotation })
+ }
+
+ return (
+
+
+ {annotations.map((annotation) => {
+ const editable = canEditAnnotation({ annotation, user })
+ const content = (
+ <>
+
+
+ {editable && !isTouchDevice && (
+
+ )}
+ >
+ )
+ return (
+
+ {editable && isTouchDevice ? (
+
+ ) : (
+
+ {content}
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/assets/js/dashboard/annotations/routeless-annotations-modals.tsx b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx
new file mode 100644
index 000000000000..5b0f19257bf7
--- /dev/null
+++ b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx
@@ -0,0 +1,183 @@
+import React from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useSiteContext } from '../site-context'
+import { useUserContext } from '../user-context'
+import { get, mutation } from '../api'
+import { useRoutelessModalsContext } from '../navigation/routeless-modals-context'
+import {
+ CreateAnnotationModal,
+ DeleteAnnotationModal,
+ UpdateAnnotationModal
+} from './annotations-modals'
+import { Annotation, AnnotationPayload } from './annotations'
+import { useDashboardStateContext } from '../dashboard-state-context'
+import { createDateRange } from '../stats-query'
+import { formatISO } from '../util/date'
+import { DashboardState } from '../dashboard-state'
+
+export const useGetAnnotations = () => {
+ const site = useSiteContext()
+ const { dashboardState } = useDashboardStateContext()
+ const date_range = createDateRange(dashboardState)
+ const relative_date = dashboardState.date
+ ? formatISO(dashboardState.date)
+ : null
+
+ const dateRangeParams: Record = Array.isArray(date_range)
+ ? { date_range_start: date_range[0], date_range_end: date_range[1] }
+ : { date_range }
+
+ const annotationsIndexQuery = useQuery({
+ queryKey: ['annotations', { date_range, relative_date }],
+ queryFn: async () => {
+ const response: Annotation[] = await get(
+ `/api/${encodeURIComponent(site.domain)}/annotations`,
+ // workaround to allow custom params to be defined
+ // without passing the whole dashboard state
+ {} as unknown as DashboardState,
+ {
+ ...dateRangeParams,
+ ...(relative_date ? { relative_date } : {})
+ }
+ )
+ return response
+ }
+ })
+ return annotationsIndexQuery
+}
+
+export type RoutelessAnnotationModal =
+ | { type: 'create-annotation'; annotation: AnnotationPayload }
+ | { type: 'update-annotation'; annotation: Annotation }
+ | { type: 'delete-annotation'; annotation: Annotation }
+
+export const RoutelessAnnotationModals = () => {
+ const queryClient = useQueryClient()
+ const site = useSiteContext()
+ const { modal, setModal } = useRoutelessModalsContext()
+ const user = useUserContext()
+
+ const patchAnnotation = useMutation({
+ mutationFn: async ({
+ id,
+ note,
+ type
+ }: Pick & Partial>) => {
+ const response: Annotation = await mutation(
+ `/api/${encodeURIComponent(site.domain)}/annotations/${id}`,
+ {
+ method: 'PATCH',
+ body: {
+ note,
+ type
+ }
+ }
+ )
+
+ return response
+ },
+ onSuccess: async () => {
+ queryClient.invalidateQueries({ queryKey: ['annotations'] })
+ setModal(null)
+ }
+ })
+
+ const createAnnotation = useMutation({
+ mutationFn: async (payload: AnnotationPayload) => {
+ const response: Annotation = await mutation(
+ `/api/${encodeURIComponent(site.domain)}/annotations`,
+ {
+ method: 'POST',
+ body: payload
+ }
+ )
+ return response
+ },
+ onSuccess: async () => {
+ queryClient.invalidateQueries({ queryKey: ['annotations'] })
+ setModal(null)
+ }
+ })
+
+ const deleteAnnotation = useMutation({
+ mutationFn: async (data: Pick) => {
+ const response: Annotation = await mutation(
+ `/api/${encodeURIComponent(site.domain)}/annotations/${data.id}`,
+ {
+ method: 'DELETE'
+ }
+ )
+ return response
+ },
+ onSuccess: (): void => {
+ queryClient.invalidateQueries({ queryKey: ['annotations'] })
+ setModal(null)
+ }
+ })
+
+ if (!user.loggedIn) {
+ return null
+ }
+
+ return (
+ <>
+ {modal?.type === 'delete-annotation' && (
+ {
+ setModal(null)
+ deleteAnnotation.reset()
+ }}
+ onSave={({ id }) => deleteAnnotation.mutate({ id })}
+ status={deleteAnnotation.status}
+ error={deleteAnnotation.error}
+ reset={deleteAnnotation.reset}
+ />
+ )}
+
+ {modal?.type === 'update-annotation' && (
+ {
+ setModal(null)
+ patchAnnotation.reset()
+ }}
+ onSave={({ id, note, type }) =>
+ patchAnnotation.mutate({
+ id,
+ note,
+ type
+ })
+ }
+ onDelete={(annotation) =>
+ setModal({ type: 'delete-annotation', annotation })
+ }
+ status={patchAnnotation.status}
+ error={patchAnnotation.error}
+ reset={patchAnnotation.reset}
+ />
+ )}
+ {modal?.type === 'create-annotation' && (
+ {
+ setModal(null)
+ createAnnotation.reset()
+ }}
+ onSave={(payload) => createAnnotation.mutate(payload)}
+ status={createAnnotation.status}
+ error={createAnnotation.error}
+ reset={createAnnotation.reset}
+ />
+ )}
+ >
+ )
+}
diff --git a/assets/js/dashboard/components/button.tsx b/assets/js/dashboard/components/button.tsx
index a4d746c69f9f..56d9fb7add5a 100644
--- a/assets/js/dashboard/components/button.tsx
+++ b/assets/js/dashboard/components/button.tsx
@@ -4,7 +4,7 @@ import classNames from 'classnames'
/**
* Themes and sizes are kept in sync with the Phoenix `button` component in
* `lib/plausible_web/components/generic.ex`. The actual Tailwind classes live
- * in `assets/css/app.css` (.btn-base, .btn-{sm,md}, .btn-theme-*).
+ * in `assets/css/app.css` (.btn-base, .btn-{xs,sm,md}, .btn-theme-*).
*/
export type ButtonTheme =
@@ -15,11 +15,12 @@ export type ButtonTheme =
| 'ghost'
| 'icon'
-export type ButtonSize = 'sm' | 'md'
+export type ButtonSize = 'xs' | 'sm' | 'md'
const buttonBaseClass = 'btn-base'
const buttonSizes: Record = {
+ xs: 'btn-xs',
sm: 'btn-sm',
md: 'btn-md'
}
diff --git a/assets/js/dashboard/components/form-elements.tsx b/assets/js/dashboard/components/form-elements.tsx
index b916f26750d4..7942dc0b6410 100644
--- a/assets/js/dashboard/components/form-elements.tsx
+++ b/assets/js/dashboard/components/form-elements.tsx
@@ -1,36 +1,139 @@
import React, { ReactNode } from 'react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
+import classNames from 'classnames'
+
+export const getCharacterCount = (value: string): number => [...value].length
+
+export const isOverMaxLength = (value: string, maxLength: number): boolean =>
+ getCharacterCount(value) > maxLength
+
+const fieldClassName =
+ 'block px-3.5 py-2.5 w-full text-sm dark:text-gray-300 rounded-md border border-gray-300 dark:border-gray-750 dark:bg-gray-750 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500'
+
+interface LabeledFieldProps {
+ label: string
+ id: string
+ value: string
+ onChange: (value: string) => void
+ placeholder: string
+ recommendedMaxLength?: number
+}
+
+const LabeledField = ({
+ label,
+ id,
+ value,
+ recommendedMaxLength,
+ children
+}: Pick<
+ LabeledFieldProps,
+ 'label' | 'id' | 'value' | 'recommendedMaxLength'
+> & {
+ children: ReactNode
+}) => (
+
+
+ {children}
+ {recommendedMaxLength !== undefined && (
+
+ )}
+
+)
export const LabeledTextInput = ({
label,
id,
value,
onChange,
- placeholder
+ placeholder,
+ recommendedMaxLength
+}: LabeledFieldProps) => (
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ aria-describedby={
+ recommendedMaxLength !== undefined ? `${id}-counter` : undefined
+ }
+ className={fieldClassName}
+ />
+
+)
+
+export const LabeledTextarea = ({
+ label,
+ id,
+ value,
+ onChange,
+ placeholder,
+ recommendedMaxLength,
+ rows = 3
+}: LabeledFieldProps & {
+ rows?: number
+}) => (
+
+
+)
+
+const CharacterCounter = ({
+ id,
+ length,
+ recommendedMaxLength
}: {
- label: string
id: string
- value: string
- onChange: (value: string) => void
- placeholder: string
+ length: number
+ recommendedMaxLength: number
}) => {
+ const overLimit = length > recommendedMaxLength
return (
-
-
- onChange(e.target.value)}
- placeholder={placeholder}
- id={id}
- className="block px-3.5 py-2.5 w-full text-sm dark:text-gray-300 rounded-md border border-gray-300 dark:border-gray-750 dark:bg-gray-750 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500"
- />
-
+ {length}
+
+
)
}
diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx
index 94524ac9dc13..792098dbfd1a 100644
--- a/assets/js/dashboard/components/graph-tooltip.tsx
+++ b/assets/js/dashboard/components/graph-tooltip.tsx
@@ -1,19 +1,13 @@
-import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react'
+import React, { ReactNode, RefObject, useLayoutEffect, useState } from 'react'
import {
Transition,
TransitionClasses,
TransitionEvents
} from '@headlessui/react'
-export const GraphTooltipWrapper = ({
- x,
- y,
- maxX,
- minWidth,
- children,
- className,
- transition
-}: {
+type GraphTooltipWrapperProps = {
+ verticalAnchor: 'topEdge' | 'bottomEdge'
+ horizontalAnchor: 'start' | 'middle'
x: number
y: number
maxX: number
@@ -21,21 +15,52 @@ export const GraphTooltipWrapper = ({
children: ReactNode
className?: string
transition?: TransitionClasses & TransitionEvents
-}) => {
- const ref = useRef(null)
+ wrapperRef: RefObject
+}
- const xOffset = 12
+export const GraphTooltipWrapper = ({
+ verticalAnchor,
+ horizontalAnchor,
+ x,
+ y,
+ maxX,
+ minWidth,
+ children,
+ className,
+ transition,
+ wrapperRef
+}: GraphTooltipWrapperProps) => {
+ const minX = 0
+ const xOffsetFromStart = 12
const [measuredWidth, setMeasuredWidth] = useState(minWidth)
const leftByAlignment = {
- alignedRight: x + xOffset,
- alignedLeft: x - xOffset - measuredWidth,
- alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth))
- }
+ start: {
+ alignedRight: x + xOffsetFromStart,
+ alignedLeft: x - xOffsetFromStart - measuredWidth,
+ alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth))
+ },
+ middle: {
+ alignedRight: x - measuredWidth / 2,
+ alignedLeft: x - measuredWidth / 2,
+ alignedRightClamped: Math.max(
+ minX,
+ Math.min(x - measuredWidth / 2, maxX - measuredWidth)
+ )
+ }
+ }[horizontalAnchor]
+
+ const canFitRight = {
+ start: leftByAlignment.alignedRight + measuredWidth <= maxX,
+ middle: x - measuredWidth / 2 >= minX && x + measuredWidth / 2 <= maxX
+ }[horizontalAnchor]
+
+ const canFitLeft = {
+ start: leftByAlignment.alignedLeft >= minX,
+ middle: false
+ }[horizontalAnchor]
- const canFitRight = leftByAlignment.alignedRight + measuredWidth <= maxX
- const canFitLeft = leftByAlignment.alignedLeft >= 0
const position = canFitRight
? 'alignedRight'
: canFitLeft
@@ -43,21 +68,29 @@ export const GraphTooltipWrapper = ({
: 'alignedRightClamped'
useLayoutEffect(() => {
- if (!ref.current) {
+ if (!wrapperRef?.current) {
return
}
- setMeasuredWidth(ref.current.offsetWidth)
- }, [children, className, minWidth])
+ const el = wrapperRef.current
+ const w = el.getBoundingClientRect().width
+ setMeasuredWidth(w)
+ }, [x, maxX, minWidth, className, children, wrapperRef])
+
+ const extraStyleByVerticalAnchor = {
+ topEdge: {},
+ bottomEdge: { transform: 'translateY(-100%)' }
+ }
return (
{children}
diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx
index 85fa23743707..b7c3831cc863 100644
--- a/assets/js/dashboard/components/graph.tsx
+++ b/assets/js/dashboard/components/graph.tsx
@@ -8,6 +8,7 @@ import React, {
import * as d3 from 'd3'
import classNames from 'classnames'
+const ANNOTATION_DIAMOND_SIZE_PX = 3
const IDEAL_Y_TICK_COUNT = 5
const MAX_X_TICK_COUNT = 8
const X_TICK_LENGTH_PX = 4
@@ -35,12 +36,14 @@ type GraphProps<
/** initial guess for left margin, automatically enlarged to fit y tick texts */
defaultMarginLeft: number
data: Datum
[]
+ annotationsByIndex: { count: number }[]
yMax: number
onPointerEnter: (event: unknown) => void
onPointerMove: PointerHandler
onPointerLeave: (event: unknown) => void
onGotPointerCapture: (event: unknown) => void
onClick?: PointerHandler
+ onContextMenu?: PointerHandler
yFormat: (domainValue: d3.NumberValue, index: number) => string
/**
* Things are drawn in the order of settings,
@@ -79,6 +82,7 @@ export function Graph({
}
const highlightIndicatorGroupId = 'highlight-indicator'
+const annotationsGroupId = 'annotations'
function InnerGraph({
className,
@@ -96,10 +100,12 @@ function InnerGraph({
onGotPointerCapture,
onPointerEnter,
onClick,
+ onContextMenu,
yFormat,
settings,
gradients,
- highlightedIndex
+ highlightedIndex,
+ annotationsByIndex
}: GraphProps) {
const [extraMarginLeft, setExtraMarginLeft] = useState(0)
const [points, setPoints] = useState[]>([])
@@ -281,6 +287,7 @@ function InnerGraph({
series,
x: point.x,
y: point.values[seriesIndex],
+ seriesIndex,
bucketIndex: i
})
points[i] = {
@@ -292,6 +299,8 @@ function InnerGraph({
}
}
+ svg.append('g').attr('id', annotationsGroupId)
+
setPoints(points)
// Unhide chart
@@ -468,6 +477,44 @@ function InnerGraph({
}
}, [onClick, isInHoverableArea, points])
+ useEffect(() => {
+ const currentSvg = svgRef.current
+ if (currentSvg && points.length) {
+ const svg = d3.select(currentSvg)
+ if (typeof onContextMenu !== 'function') {
+ svg.on('contextmenu', null)
+ } else {
+ svg.on('contextmenu', (event) => {
+ const { xPointer, yPointer } = getPosition(event)
+ const inHoverableArea = isInHoverableArea(xPointer, yPointer)
+ const closestIndexToPointer = inHoverableArea
+ ? getClosestIndexToPointer(xPointer, points)
+ : null
+ onContextMenu({
+ inHoverableArea,
+ closestPoint:
+ closestIndexToPointer !== null
+ ? {
+ index: closestIndexToPointer,
+ x: points[closestIndexToPointer].x,
+ values: points[closestIndexToPointer].values
+ }
+ : null,
+ xPointer,
+ yPointer,
+ event
+ })
+ })
+ }
+ }
+ return () => {
+ if (currentSvg) {
+ const svg = d3.select(currentSvg)
+ svg.on('contextmenu', null)
+ }
+ }
+ }, [onContextMenu, isInHoverableArea, points])
+
useEffect(() => {
points.forEach(({ dots }, index) =>
dots.forEach((g) =>
@@ -504,6 +551,43 @@ function InnerGraph({
}
}, [height, highlightedIndex, marginBottom, marginTop, points])
+ useEffect(() => {
+ if (!svgRef.current) {
+ return
+ }
+ const svg = d3.select(svgRef.current)
+ const cleanup = () => {
+ svg.selectAll(`#${annotationsGroupId} g`).remove()
+ }
+ if (!points.length || points.length !== annotationsByIndex.length) {
+ return cleanup()
+ }
+
+ const pointsWithAnnotations = points.map((point, index) => {
+ return { ...point, index, annotations: annotationsByIndex[index] }
+ })
+
+ for (const point of pointsWithAnnotations) {
+ if (point.annotations.count > 0) {
+ svg
+ .select(`#${annotationsGroupId}`)
+ .append('g')
+ .call((g) =>
+ g
+ .append('polygon')
+ .attr(
+ 'points',
+ `${-ANNOTATION_DIAMOND_SIZE_PX},0 0,${ANNOTATION_DIAMOND_SIZE_PX} ${ANNOTATION_DIAMOND_SIZE_PX},0 0,${-ANNOTATION_DIAMOND_SIZE_PX}`
+ )
+ .attr('transform', `translate(${point.x}, ${yBottomEdge})`)
+ .attr('class', annotationDiamondClass)
+ .attr('data-testid', `annotation-marker-on-bucket-${point.index}`)
+ )
+ }
+ }
+ return cleanup
+ }, [points, annotationsByIndex, yBottomEdge])
+
return (
)
+export const PencilIcon = ({ className }: { className?: string }) => (
+
+)
+
export const CursorIcon = ({ className }: { className?: string }) => (