Annotations workflow, UI/UX polish
This commit is contained in:
1 parent
26e236c95c
commit
336fbcb3c5
18 files changed
+463
-87
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
ChartAnnotationMarkers,
|
||||
} from '@/components/charts/ChartAnnotationMarkers';
|
||||
import { Legend } from '@/components/metrics/Legend';
|
||||
import { getChartBucketIndex } from '@/lib/charts';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
|
||||
ChartJS.defaults.font.family = 'Inter';
|
||||
@@ -26,7 +27,8 @@ export interface ChartProps extends BoxProps {
|
||||
hiddenLabels?: Set<string>;
|
||||
onLegendClick?: (label: string, willBeHidden: boolean) => void;
|
||||
annotations?: ChartAnnotation[];
|
||||
onAnnotationClick?: (annotation: ChartAnnotation) => void;
|
||||
onAnnotationClick?: (annotations: ChartAnnotation[]) => void;
|
||||
onAnnotationMoreClick?: (annotations: ChartAnnotation[]) => void;
|
||||
}
|
||||
|
||||
function isSameMarkers(a: AnnotationMarker[], b: AnnotationMarker[]) {
|
||||
@@ -53,6 +55,7 @@ export function Chart({
|
||||
onLegendClick,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
onAnnotationMoreClick,
|
||||
...props
|
||||
}: ChartProps) {
|
||||
const canvas = useRef(null);
|
||||
@@ -79,7 +82,13 @@ export function Chart({
|
||||
const groups = new Map<number, AnnotationMarker>();
|
||||
|
||||
for (const annotation of list) {
|
||||
const x = Math.round(scales.x.getPixelForValue(annotation.date.getTime()));
|
||||
const markerDate = annotation.markerDate || annotation.date;
|
||||
const bucketIndex = getChartBucketIndex(
|
||||
instance.data.datasets[0]?.data || [],
|
||||
markerDate,
|
||||
);
|
||||
const bucket = instance.getDatasetMeta(0)?.data[bucketIndex];
|
||||
const x = Math.round(bucket?.x ?? scales.x.getPixelForValue(markerDate.getTime()));
|
||||
|
||||
if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) {
|
||||
continue;
|
||||
@@ -217,7 +226,11 @@ export function Chart({
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<canvas ref={canvas} style={{ position: 'absolute', top: 0, left: 0 }} />
|
||||
{markers.length > 0 && (
|
||||
<ChartAnnotationMarkers markers={markers} onClick={onAnnotationClick} />
|
||||
<ChartAnnotationMarkers
|
||||
markers={markers}
|
||||
onClick={onAnnotationClick}
|
||||
onMoreClick={onAnnotationMoreClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -37,6 +37,34 @@
|
||||
|
||||
.note {
|
||||
max-width: 300px;
|
||||
max-height: min(280px, 50vh);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.noteFull {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.noteText {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.moreButton {
|
||||
align-self: center;
|
||||
min-height: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.moreButton:hover,
|
||||
.moreButton:focus-visible {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from '@/components/hooks';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { useLocale, useMessages } from '@/components/hooks';
|
||||
import { Maximize } from '@/components/icons';
|
||||
import { getThemeColors } from '@/lib/colors';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import { Button, Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
|
||||
import { isSameDay } from 'date-fns';
|
||||
import { useMemo } from 'react';
|
||||
import styles from './ChartAnnotationMarkers.module.css';
|
||||
|
||||
const MAX_TOOLTIP_ANNOTATIONS = 3;
|
||||
|
||||
export interface ChartAnnotation {
|
||||
id: string;
|
||||
date: Date;
|
||||
markerDate?: Date;
|
||||
label: string;
|
||||
allDay?: boolean;
|
||||
isClickable?: boolean;
|
||||
isGroupClickable?: boolean;
|
||||
}
|
||||
|
||||
export interface AnnotationMarker {
|
||||
@@ -25,40 +33,80 @@ export function formatAnnotationDate(annotation: ChartAnnotation, locale: string
|
||||
export function ChartAnnotationMarkers({
|
||||
markers,
|
||||
onClick,
|
||||
onMoreClick,
|
||||
}: {
|
||||
markers: AnnotationMarker[];
|
||||
onClick?: (annotation: ChartAnnotation) => void;
|
||||
onClick?: (annotations: ChartAnnotation[]) => void;
|
||||
onMoreClick?: (annotations: ChartAnnotation[]) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
const { t, labels } = useMessages();
|
||||
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ annotations, x, y }) => {
|
||||
const [first] = annotations;
|
||||
const hasOneDate = annotations.every(annotation => isSameDay(annotation.date, first.date));
|
||||
const isClickable =
|
||||
!!onClick &&
|
||||
(hasOneDate
|
||||
? first.isClickable !== false
|
||||
: annotations.every(annotation => annotation.isGroupClickable === true));
|
||||
const visibleAnnotations = annotations.slice(0, MAX_TOOLTIP_ANNOTATIONS);
|
||||
|
||||
return (
|
||||
<TooltipTrigger key={first.id} delay={0}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{ left: x, top: y, backgroundColor: colors.chart.views.hoverBorderColor }}
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
backgroundColor: colors.chart.views.hoverBorderColor,
|
||||
}}
|
||||
aria-label={annotations.map(({ label }) => label).join(', ')}
|
||||
onClick={() => onClick?.(first)}
|
||||
onClick={isClickable ? () => onClick?.(annotations) : undefined}
|
||||
>
|
||||
{annotations.length > 1 && <span className={styles.count}>{annotations.length}</span>}
|
||||
</button>
|
||||
<Tooltip placement="top">
|
||||
<Column gap="3" className={styles.note}>
|
||||
{annotations.map(annotation => (
|
||||
<Column
|
||||
gap="3"
|
||||
padding="1"
|
||||
borderRadius="md"
|
||||
className={styles.note}
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.2)', color: 'white' }}
|
||||
>
|
||||
{visibleAnnotations.map(annotation => (
|
||||
<Column key={annotation.id} gap="1">
|
||||
<Text size="sm" weight="bold">
|
||||
{formatAnnotationDate(annotation, locale)}
|
||||
</Text>
|
||||
<Text size="sm">{annotation.label}</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
className={annotations.length === 1 ? styles.noteFull : styles.noteText}
|
||||
>
|
||||
{annotation.label}
|
||||
</Text>
|
||||
</Column>
|
||||
))}
|
||||
{annotations.length > 1 &&
|
||||
(onMoreClick ? (
|
||||
<Button
|
||||
variant="quiet"
|
||||
className={styles.moreButton}
|
||||
onPress={() => onMoreClick(annotations)}
|
||||
>
|
||||
<IconLabel icon={<Maximize />}>{t(labels.view)}</IconLabel>
|
||||
</Button>
|
||||
) : (
|
||||
<IconLabel icon={<Maximize />} style={{ opacity: 0.8 }}>
|
||||
{t(labels.view)}
|
||||
</IconLabel>
|
||||
))}
|
||||
</Column>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export interface ConfirmationFormProps {
|
||||
message: ReactNode;
|
||||
message?: ReactNode;
|
||||
buttonLabel?: ReactNode;
|
||||
buttonVariant?: 'primary' | 'quiet' | 'danger';
|
||||
isLoading?: boolean;
|
||||
@@ -25,7 +25,7 @@ export function ConfirmationForm({
|
||||
|
||||
return (
|
||||
<Form onSubmit={onConfirm} error={getErrorMessage(error)}>
|
||||
<Box marginY="4">{message}</Box>
|
||||
{message && <Box marginY="4">{message}</Box>}
|
||||
<FormButtons>
|
||||
<Button onPress={onClose}>{t(labels.cancel)}</Button>
|
||||
<FormSubmitButton
|
||||
|
||||
@@ -449,6 +449,7 @@ export const messages: Record<string, string> = {
|
||||
confirmDelete: 'message.confirm-delete',
|
||||
confirmRemove: 'message.confirm-remove',
|
||||
confirmLeave: 'message.confirm-leave',
|
||||
maxLength: 'message.max-length',
|
||||
minPasswordLength: 'message.min-password-length',
|
||||
noTeams: 'message.no-teams',
|
||||
shareUrl: 'message.share-url',
|
||||
|
||||
Reference in new issue
Block a user