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 +}) => ( + +