From af0182c5ed85348d96d3639ec1b64c5ca140d487 Mon Sep 17 00:00:00 2001
From: Sanne de Vries
Date: Thu, 21 May 2026 16:32:17 +0200
Subject: [PATCH] Iterate on initial annotations UI
Graph/tooltip:
- Change from 1 to 2 visible notes on hover
- Add label to note indicating `Personal note`, or `[Author name]` for site-wide notes
- Improve button styles
- Add edit button to note on hover
- Add click handler to note on touch devices for editing
- Remove pin, delete and edit buttons from note on click
- Disable editing notes from other authors
Modals:
- Change date format in title
- Add textarea field to `form-elements.tsx` and use it in annotation modals
- Add visible character limit and character counter to note field
- Add `Delete note` button to edit modal
---
assets/css/app.css | 4 +
.../annotations/annotations-modals.tsx | 67 +++++-
.../js/dashboard/annotations/annotations.ts | 14 ++
.../routeless-annotations-modals.tsx | 3 +
assets/js/dashboard/components/button.tsx | 5 +-
.../js/dashboard/components/form-elements.tsx | 143 ++++++++++--
assets/js/dashboard/components/icons.tsx | 17 ++
.../js/dashboard/stats/graph/main-graph.tsx | 203 ++++++++----------
8 files changed, 311 insertions(+), 145 deletions(-)
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/annotations-modals.tsx b/assets/js/dashboard/annotations/annotations-modals.tsx
index 90e02c240e1b..b358941be2f5 100644
--- a/assets/js/dashboard/annotations/annotations-modals.tsx
+++ b/assets/js/dashboard/annotations/annotations-modals.tsx
@@ -2,6 +2,7 @@ import React, { ReactNode, useState } from 'react'
import {
Annotation,
ANNOTATION_TYPE_LABELS,
+ AnnotationGranularity,
AnnotationPayload,
AnnotationType
} from './annotations'
@@ -14,14 +15,38 @@ import {
SaveButton
} from '../components/modal-layout'
import {
- LabeledTextInput,
+ 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
@@ -68,14 +93,20 @@ export const CreateAnnotationModal = ({
? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
: null
+ const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)
+
return (
-
-
+
{
const trimmedNote = note.trim()
const saveableNote = trimmedNote.length
@@ -259,6 +292,7 @@ export const DeleteAnnotationModal = ({
export const UpdateAnnotationModal = ({
onClose,
onSave,
+ onDelete,
annotation,
siteAnnotationsAvailable,
user,
@@ -269,6 +303,7 @@ export const UpdateAnnotationModal = ({
}: AnnotationModalProps &
ApiRequestProps & {
onSave: (input: Pick) => void
+ onDelete?: (annotation: Annotation) => void
annotation: Annotation
}) => {
const [note, setNote] = useState(annotation.note)
@@ -279,14 +314,20 @@ export const UpdateAnnotationModal = ({
? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
: null
+ const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)
+
return (
-
-
+
+ {typeof onDelete === 'function' && (
+
+ )}
{
const trimmedNote = note.trim()
const saveableNote = trimmedNote.length
diff --git a/assets/js/dashboard/annotations/annotations.ts b/assets/js/dashboard/annotations/annotations.ts
index f65ecef59dcf..eccb4c43137c 100644
--- a/assets/js/dashboard/annotations/annotations.ts
+++ b/assets/js/dashboard/annotations/annotations.ts
@@ -51,6 +51,20 @@ export const ANNOTATION_TYPE_LABELS = {
[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 canEditAnnotation = (
+ annotation: Pick,
+ userId: number | null
+): boolean => userId !== null && annotation.owner_id === userId
+
export const getAnnotationTimeLabel = (
annotation: Pick,
interval: Interval
diff --git a/assets/js/dashboard/annotations/routeless-annotations-modals.tsx b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx
index edf88ceb660e..eafd94293f69 100644
--- a/assets/js/dashboard/annotations/routeless-annotations-modals.tsx
+++ b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx
@@ -131,6 +131,9 @@ export const RoutelessAnnotationModals = () => {
type
})
}
+ onDelete={(annotation) =>
+ setModal({ type: 'delete-annotation', annotation })
+ }
status={patchAnnotation.status}
error={patchAnnotation.error}
reset={patchAnnotation.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/icons.tsx b/assets/js/dashboard/components/icons.tsx
index 5047e2b780c3..0d3be9a13051 100644
--- a/assets/js/dashboard/components/icons.tsx
+++ b/assets/js/dashboard/components/icons.tsx
@@ -79,6 +79,23 @@ export const RefreshIcon = ({ className }: { className?: string }) => (
)
+export const PencilIcon = ({ className }: { className?: string }) => (
+
+)
+
export const CursorIcon = ({
className,
title
diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx
index db3b38b6649d..c3b25769d94a 100644
--- a/assets/js/dashboard/stats/graph/main-graph.tsx
+++ b/assets/js/dashboard/stats/graph/main-graph.tsx
@@ -48,11 +48,15 @@ import {
AnnotationType,
AnnotationWithPinState,
PinPosition,
+ canEditAnnotation,
enrichAnnotationsWithPinState,
+ getAnnotationAttribution,
getAnnotationGranularity,
groupAnnotationsByTimeLabel
} from '../../annotations/annotations'
+import { useUserContext } from '../../user-context'
import { Button } from '../../components/button'
+import { PencilIcon } from '../../components/icons'
const height = 368
const marginTop = 16
@@ -113,7 +117,7 @@ export const MainGraph = ({
useEffect(() => {
setTooltip(initialTooltipState)
}, [width])
-
+
useEffect(() => {
setPinnedAnnotationIds({})
}, [width, data])
@@ -561,19 +565,24 @@ export const MainGraph = ({
}))
}
annotations={annotationsByTimeLabel[annotationDatetime]}
+ isTouchDevice={!!isTouchDevice}
/>
)}
- {!!annotationDatetime && (
-
- )}
- {!!zoomDate && (
-
- )}
+
+ {!!annotationDatetime && (
+
+ )}
+ {!!zoomDate && (
+
+ )}
+
>
)}
{!tooltip.persistent && (
@@ -582,31 +591,31 @@ export const MainGraph = ({
!!annotationsByTimeLabel[annotationDatetime] && (
<>
{}}
+ ].slice(0, 2)}
/>
- {annotationsByTimeLabel[annotationDatetime].length == 2 &&
+ {annotationsByTimeLabel[annotationDatetime].length == 3 &&
`and 1 more note`}
- {annotationsByTimeLabel[annotationDatetime].length > 2 &&
- `and ${annotationsByTimeLabel[annotationDatetime].length - 1} more notes`}
+ {annotationsByTimeLabel[annotationDatetime].length > 3 &&
+ `and ${annotationsByTimeLabel[annotationDatetime].length - 2} more notes`}
>
)}
{(!!zoomDate || !!annotationDatetime) && (
)}
- {!!zoomDate && (
-
- {`Click to view ${interval}`}
-
- )}
- {!!annotationDatetime && (
-
- Right click for more actions
-
- )}
+
+ {!!zoomDate && (
+
+ {`Click to view ${interval}`}
+
+ )}
+ {!!annotationDatetime && (
+
+ Right click for more actions
+
+ )}
+
>
)}
@@ -617,117 +626,78 @@ export const MainGraph = ({
const InteractiveAnnotationsList = ({
annotations,
- onPin
+ isTouchDevice,
+ onPin: _onPin
}: {
onPin: (annotation: Annotation) => void
annotations: AnnotationWithPinState[]
+ isTouchDevice: boolean
}) => {
- const [expanded, setExpanded] = useState(null)
- useEffect(() => {
- setExpanded(null)
- }, [annotations])
const { setModal } = useRoutelessModalsContext()
+ const user = useUserContext()
return (
- setExpanded((current) => (current === index ? null : index))
- }
onEdit={(annotation) =>
setModal({ type: 'update-annotation', annotation })
}
- onDelete={(annotation) =>
- setModal({ type: 'delete-annotation', annotation })
- }
- onPin={onPin}
+ canEdit={(annotation) => canEditAnnotation(annotation, user.id)}
+ isTouchDevice={isTouchDevice}
/>
)
}
const AnnotationsList = ({
annotations,
- expandedIndex,
- onAnnotationClick,
onEdit,
- onPin,
- onDelete
+ canEdit,
+ isTouchDevice
}: {
annotations: AnnotationWithPinState[]
onEdit?: (annotation: Annotation) => void
- onPin?: (annotation: Annotation) => void
- onDelete?: (annotation: Annotation) => void
- expandedIndex: number | null
- onAnnotationClick?: (index: number) => void
+ canEdit?: (annotation: Annotation) => boolean
+ isTouchDevice?: boolean
}) => {
return (
-
- {annotations.map((annotation, index) => {
+
+ {annotations.map((annotation) => {
const { id, note } = annotation
+ const attribution = getAnnotationAttribution(annotation)
+ const interactive =
+ typeof onEdit === 'function' && (!canEdit || canEdit(annotation))
+ const content = (
+ <>
+
{attribution}
+
+ {note}
+
+ {interactive && !isTouchDevice && (
+
+ )}
+ >
+ )
return (
-
+
-
- {typeof onAnnotationClick === 'function' ? (
-
- ) : (
-
{note}
- )}
- {expandedIndex === index && (
-
- {typeof onEdit === 'function' && (
-
- )}
- {typeof onPin === 'function' &&
- (annotation.isPinned ? (
-
- ) : (
-
- ))}
- {typeof onDelete === 'function' && (
-
- )}
-
- )}
-
+ {interactive && isTouchDevice ? (
+
+ ) : (
+
+ {content}
+
+ )}
)
})}
@@ -746,12 +716,13 @@ const AddAnnotationButton = ({
return (