Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
disabled:cursor-not-allowed;
}

.btn-xs {
@apply px-2 py-1;
}

.btn-sm {
@apply px-3 py-2;
}
Expand Down
67 changes: 60 additions & 7 deletions assets/js/dashboard/annotations/annotations-modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { ReactNode, useState } from 'react'
import {
Annotation,
ANNOTATION_TYPE_LABELS,
AnnotationGranularity,
AnnotationPayload,
AnnotationType
} from './annotations'
Expand All @@ -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
Expand Down Expand Up @@ -68,14 +93,20 @@ export const CreateAnnotationModal = ({
? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
: null

const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)

return (
<ModalLayout title={`Add note for ${datetime}`} onClose={onClose}>
<LabeledTextInput
<ModalLayout
title={`Add note for ${formatAnnotationDatetime(datetime, granularity)}`}
onClose={onClose}
>
<LabeledTextarea
label="Note"
id="note"
value={note}
onChange={setNote}
placeholder={notePlaceholder}
recommendedMaxLength={NOTE_RECOMMENDED_MAX_LENGTH}
/>
<AnnotationTypeSelector
value={type}
Expand All @@ -87,7 +118,9 @@ export const CreateAnnotationModal = ({
Cancel
</Button>
<SaveButton
disabled={status === 'pending' || disabledMessage !== null}
disabled={
status === 'pending' || overLimit || disabledMessage !== null
}
onSave={() => {
const trimmedNote = note.trim()
const saveableNote = trimmedNote.length
Expand Down Expand Up @@ -259,6 +292,7 @@ export const DeleteAnnotationModal = ({
export const UpdateAnnotationModal = ({
onClose,
onSave,
onDelete,
annotation,
siteAnnotationsAvailable,
user,
Expand All @@ -269,6 +303,7 @@ export const UpdateAnnotationModal = ({
}: AnnotationModalProps &
ApiRequestProps & {
onSave: (input: Pick<Annotation, 'id' | 'note' | 'type'>) => void
onDelete?: (annotation: Annotation) => void
annotation: Annotation
}) => {
const [note, setNote] = useState(annotation.note)
Expand All @@ -279,26 +314,44 @@ export const UpdateAnnotationModal = ({
? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user })
: null

const overLimit = isOverMaxLength(note, NOTE_RECOMMENDED_MAX_LENGTH)

return (
<ModalLayout title="Update note" onClose={onClose}>
<LabeledTextInput
<ModalLayout
title={`Update note for ${formatAnnotationDatetime(annotation.datetime, annotation.granularity)}`}
onClose={onClose}
>
<LabeledTextarea
label="Note"
id="note"
value={note}
onChange={setNote}
placeholder={notePlaceholder}
recommendedMaxLength={NOTE_RECOMMENDED_MAX_LENGTH}
/>
<AnnotationTypeSelector
value={type}
onChange={setType}
optionDisabledMessage={disabledMessage}
/>
<ModalFooter>
{typeof onDelete === 'function' && (
<Button
theme="danger"
size="sm"
className="mr-auto"
onClick={() => onDelete(annotation)}
>
Delete note
</Button>
)}
<Button theme="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<SaveButton
disabled={status === 'pending' || disabledMessage !== null}
disabled={
status === 'pending' || overLimit || disabledMessage !== null
}
onSave={() => {
const trimmedNote = note.trim()
const saveableNote = trimmedNote.length
Expand Down
14 changes: 14 additions & 0 deletions assets/js/dashboard/annotations/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ export const ANNOTATION_TYPE_LABELS = {
[AnnotationType.site]: 'Site-wide note'
}

export const getAnnotationAttribution = (
annotation: Pick<Annotation, 'type' | 'owner_name'>
): string => {
if (annotation.type === AnnotationType.site && annotation.owner_name) {
return annotation.owner_name
}
return ANNOTATION_TYPE_LABELS[annotation.type]
}

export const canEditAnnotation = (
annotation: Pick<Annotation, 'owner_id'>,
userId: number | null
): boolean => userId !== null && annotation.owner_id === userId

export const getAnnotationTimeLabel = (
annotation: Pick<Annotation, 'datetime' | 'granularity'>,
interval: Interval
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export const RoutelessAnnotationModals = () => {
type
})
}
onDelete={(annotation) =>
setModal({ type: 'delete-annotation', annotation })
}
status={patchAnnotation.status}
error={patchAnnotation.error}
reset={patchAnnotation.reset}
Expand Down
5 changes: 3 additions & 2 deletions assets/js/dashboard/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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<ButtonSize, string> = {
xs: 'btn-xs',
sm: 'btn-sm',
md: 'btn-md'
}
Expand Down
143 changes: 123 additions & 20 deletions assets/js/dashboard/components/form-elements.tsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<div className="flex flex-col">
<label
htmlFor={id}
className="block mb-1.5 text-sm font-medium dark:text-gray-100 text-gray-700 dark:text-gray-300"
>
{label}
</label>
{children}
{recommendedMaxLength !== undefined && (
<CharacterCounter
id={`${id}-counter`}
length={getCharacterCount(value)}
recommendedMaxLength={recommendedMaxLength}
/>
)}
</div>
)

export const LabeledTextInput = ({
label,
id,
value,
onChange,
placeholder
placeholder,
recommendedMaxLength
}: LabeledFieldProps) => (
<LabeledField
label={label}
id={id}
value={value}
recommendedMaxLength={recommendedMaxLength}
>
<input
autoComplete="off"
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-describedby={
recommendedMaxLength !== undefined ? `${id}-counter` : undefined
}
className={fieldClassName}
/>
</LabeledField>
)

export const LabeledTextarea = ({
label,
id,
value,
onChange,
placeholder,
recommendedMaxLength,
rows = 3
}: LabeledFieldProps & {
rows?: number
}) => (
<LabeledField
label={label}
id={id}
value={value}
recommendedMaxLength={recommendedMaxLength}
>
<textarea
autoComplete="off"
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
aria-describedby={
recommendedMaxLength !== undefined ? `${id}-counter` : undefined
}
className={classNames(fieldClassName, 'resize-y leading-5')}
/>
</LabeledField>
)

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 (
<div className="flex flex-col">
<label
htmlFor={id}
className="block mb-1.5 text-sm font-medium dark:text-gray-100 text-gray-700 dark:text-gray-300"
<p
id={id}
className="mt-1.5 text-xs text-gray-500 dark:text-gray-400"
aria-live="polite"
>
{`Recommended: ${recommendedMaxLength} characters. You've used `}
<span
className={classNames('font-semibold', {
'text-red-500 dark:text-red-400': overLimit
})}
>
{label}
</label>
<input
autoComplete="off"
value={value}
onChange={(e) => 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"
/>
</div>
{length}
</span>
</p>
)
}

Expand Down
17 changes: 17 additions & 0 deletions assets/js/dashboard/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ export const RefreshIcon = ({ className }: { className?: string }) => (
</svg>
)

export const PencilIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
fill="none"
className={className}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="m10.547 4.422 3.031 3.031M2.75 15.25s3.599-.568 4.546-1.515l7.327-7.327a2.142 2.142 0 1 0-3.03-3.03l-7.327 7.327c-.947.947-1.515 4.546-1.515 4.546h0Z"
/>
</svg>
)

export const CursorIcon = ({
className,
title
Expand Down
Loading
Loading