diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fd7d5a89d4..271ed7238e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Annotations feature - Pages report can now be broken down by URL (hostname + path) in addition to path only - New "AI Assistants" acquisition channel + improved recognized sources database - Allow querying revenue metrics (`total_revenue`, `average_revenue`) with visit dimensions in Stats API v2 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..c3afdb979050 --- /dev/null +++ b/assets/js/dashboard/annotations/annotations-modals.tsx @@ -0,0 +1,379 @@ +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, + getOptionDisabledMessage, + isOverMaxLength, + OptionDisabledMessageType +} from '../components/form-elements' +import { Button } from '../components/button' +import { UpgradePill } from '../components/pill' +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 siteOptionDisabledMessage = getAnnotationTypeDisabledMessage({ + siteAnnotationsAvailable, + user + }) + + const disabledMessage = + type === AnnotationType.site ? siteOptionDisabledMessage : 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, + siteOptionDisabledMessage +}: { + value: AnnotationType + onChange: (value: AnnotationType) => void + siteOptionDisabledMessage: 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', + disabled: siteOptionDisabledMessage !== null, + pill: + siteOptionDisabledMessage === 'upgrade-subscription-yourself' || + siteOptionDisabledMessage === 'upgrade-subscription-reach-out' ? ( + + ) : null, + tooltipContent: + siteOptionDisabledMessage !== null ? ( + + ) : 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 'Upgrade to Growth to make notes visible to others.' + case 'upgrade-subscription-reach-out': + return 'Ask a team owner to upgrade to Growth to make notes visible to others.' + } +} + +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 siteOptionDisabledMessage = getAnnotationTypeDisabledMessage({ + siteAnnotationsAvailable, + user + }) + + const disabledMessage = + type === AnnotationType.site ? siteOptionDisabledMessage : 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..0daf50a9ec25 --- /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 annotation with an owner', () => { + expect( + getAnnotationAttribution({ + type: AnnotationType.site, + owner_name: 'Alice' + }) + ).toBe('Alice') + }) + + it('returns "Site note" for a site annotation with a dangling owner', () => { + expect( + getAnnotationAttribution({ + type: AnnotationType.site, + owner_name: null + }) + ).toBe('Site 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..54bcd49e3eff --- /dev/null +++ b/assets/js/dashboard/annotations/annotations.ts @@ -0,0 +1,235 @@ +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 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 + } +} + +export const getApiFormattedPayload = ({ + granularity, + datetime, + ...payload +}: AnnotationPayload) => { + switch (granularity) { + case AnnotationGranularity.date: + return { date: datetime, granularity, ...payload } + case AnnotationGranularity.minute: + return { datetime, granularity, ...payload } + } +} 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..a91d53806db5 --- /dev/null +++ b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx @@ -0,0 +1,187 @@ +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, + getApiFormattedPayload +} 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: getApiFormattedPayload(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/feature-setup-notice.tsx b/assets/js/dashboard/components/feature-setup-notice.tsx index 7fd9a349a8b4..fa06308beebf 100644 --- a/assets/js/dashboard/components/feature-setup-notice.tsx +++ b/assets/js/dashboard/components/feature-setup-notice.tsx @@ -3,19 +3,9 @@ import classNames from 'classnames' import { MODES } from '../stats/behaviours/modes-context' import * as api from '../api' import { useSiteContext } from '../site-context' -import { Pill } from './pill' -import { DiamondIcon } from './icons' +import { UpgradePill } from './pill' import { buttonClassName } from './button' -function BusinessPill() { - return ( - - - Business - - ) -} - export function FeatureSetupNotice({ feature, title, @@ -121,7 +111,7 @@ export function FeatureSetupNotice({ > {previewMock && (
- +
)}
diff --git a/assets/js/dashboard/components/form-elements.tsx b/assets/js/dashboard/components/form-elements.tsx index b916f26750d4..8a62cafb72a4 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' +import { Tooltip } from '../util/tooltip' + +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 +}) => ( + +