fix: a lot of minor improvements for dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-07 12:28:54 +01:00
parent 5b1c582023
commit c762bd7c95
19 changed files with 591 additions and 388 deletions

View File

@@ -3,7 +3,7 @@ import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export function createChartTooltip< export function createChartTooltip<
PropsFromTooltip extends Record<string, unknown>, PropsFromTooltip,
PropsFromContext extends Record<string, unknown>, PropsFromContext extends Record<string, unknown>,
>( >(
Tooltip: React.ComponentType< Tooltip: React.ComponentType<

View File

@@ -8,7 +8,7 @@ export function FeedbackButton() {
return ( return (
<Button <Button
variant={'outline'} variant={'outline'}
className="w-full text-left justify-start [&_svg]:mx-2" className="text-left justify-start"
icon={SparklesIcon} icon={SparklesIcon}
onClick={() => { onClick={() => {
op.track('feedback_button_clicked'); op.track('feedback_button_clicked');

View File

@@ -4,6 +4,9 @@ import { useQuery } from '@tanstack/react-query';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
@@ -18,7 +21,6 @@ import {
} from 'recharts'; } from 'recharts';
import { BarShapeBlue } from '../charts/common-bar'; import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps { interface OverviewLiveHistogramProps {
projectId: string; projectId: string;
} }
@@ -120,26 +122,38 @@ function Wrapper({ children, count, icons }: WrapperProps) {
// Custom tooltip component that uses portals to escape overflow hidden // Custom tooltip component that uses portals to escape overflow hidden
const CustomTooltip = ({ active, payload, coordinate }: any) => { const CustomTooltip = ({ active, payload, coordinate }: any) => {
const [tooltipContainer] = useState(() => document.createElement('div'));
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const number = useNumber(); const number = useNumber();
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const inactive = !active || !payload?.length;
useEffect(() => { useEffect(() => {
document.body.appendChild(tooltipContainer); const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
const handleMouseMove = (e: MouseEvent) => { type: 'mousemove',
setMousePosition({ x: e.clientX, y: e.clientY }); listener(event) {
}; if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
document.addEventListener('mousemove', handleMouseMove); }
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => { return () => {
if (document.body.contains(tooltipContainer)) { unsubMouseMove();
document.body.removeChild(tooltipContainer); unsubDragEnter();
}
document.removeEventListener('mousemove', handleMouseMove);
}; };
}, [tooltipContainer]); }, [inactive]);
if (inactive) {
return null;
}
if (!active || !payload || !payload.length) { if (!active || !payload || !payload.length) {
return null; return null;
@@ -147,44 +161,31 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
const data = payload[0].payload; const data = payload[0].payload;
// Smart positioning to avoid going out of bounds const tooltipWidth = 200;
const tooltipWidth = 220; // min-w-[220px] to accommodate referrers const correctXPosition = (x: number | undefined) => {
const tooltipHeight = 120; // approximate height with referrers if (!x) {
const offset = 10; return undefined;
}
let left = mousePosition.x + offset; const screenWidth = window.innerWidth;
let top = mousePosition.y - offset; const newX = x;
// Check if tooltip would go off the right edge if (newX + tooltipWidth > screenWidth) {
if (left + tooltipWidth > window.innerWidth) { return screenWidth - tooltipWidth;
left = mousePosition.x - tooltipWidth - offset; }
} return newX;
};
// Check if tooltip would go off the left edge return (
if (left < 0) { <Portal.Portal
left = offset;
}
// Check if tooltip would go off the top edge
if (top < 0) {
top = mousePosition.y + offset;
}
// Check if tooltip would go off the bottom edge
if (top + tooltipHeight > window.innerHeight) {
top = window.innerHeight - tooltipHeight - offset;
}
const tooltipContent = (
<div
className="flex min-w-[220px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
style={{ style={{
position: 'fixed', position: 'fixed',
top, top: position?.y,
left, left: correctXPosition(position?.x),
pointerEvents: 'none',
zIndex: 1000, zIndex: 1000,
width: tooltipWidth,
}} }}
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
> >
<div className="flex justify-between gap-8 text-muted-foreground"> <div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.time}</div> <div>{data.time}</div>
@@ -234,8 +235,6 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
</div> </Portal.Portal>
); );
return createPortal(tooltipContent, tooltipContainer);
}; };

View File

@@ -17,11 +17,13 @@ import { last, omit } from 'ramda';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Bar, Bar,
BarChart,
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
Customized, Customized,
Line, Line,
LineChart, LineChart,
ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
@@ -128,6 +130,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
}))} }))}
active={metric === index} active={metric === index}
isLoading={overviewQuery.isLoading} isLoading={overviewQuery.isLoading}
inverted={title.inverted}
/> />
))} ))}
@@ -179,6 +182,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
interval={interval} interval={interval}
data={data} data={data}
chartType={chartType} chartType={chartType}
projectId={projectId}
/> />
</div> </div>
</div> </div>
@@ -244,15 +248,33 @@ function Chart({
interval, interval,
data, data,
chartType, chartType,
projectId,
}: { }: {
activeMetric: (typeof TITLES)[number]; activeMetric: (typeof TITLES)[number];
interval: IInterval; interval: IInterval;
data: RouterOutputs['overview']['stats']['series']; data: RouterOutputs['overview']['stats']['series'];
chartType: 'bars' | 'lines'; chartType: 'bars' | 'lines';
projectId: string;
}) { }) {
const xAxisProps = useXAxisProps({ interval }); const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps(); const yAxisProps = useYAxisProps();
const [activeBar, setActiveBar] = useState(-1); const [activeBar, setActiveBar] = useState(-1);
const { range, startDate, endDate } = useOverviewOptions();
const trpc = useTRPC();
const references = useQuery(
trpc.reference.getChartReferences.queryOptions(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
},
),
);
// Line chart specific logic // Line chart specific logic
let dotIndex = undefined; let dotIndex = undefined;
@@ -372,6 +394,22 @@ function Chart({
r: 4, r: 4,
}} }}
/> />
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
@@ -382,7 +420,7 @@ function Chart({
return ( return (
<TooltipProvider metric={activeMetric} interval={interval}> <TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart <BarChart
data={data} data={data}
margin={{ top: 0, right: 0, left: 0, bottom: 10 }} margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
onMouseMove={(e) => { onMouseMove={(e) => {
@@ -426,7 +464,23 @@ function Chart({
<BarShapeBlue isActive={activeBar === props.index} {...props} /> <BarShapeBlue isActive={activeBar === props.index} {...props} />
)} )}
/> />
</ComposedChart>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
</BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -5,6 +5,7 @@ interface PageHeaderProps {
description?: string; description?: string;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
actions?: React.ReactNode;
} }
export function PageHeader({ export function PageHeader({
@@ -12,14 +13,18 @@ export function PageHeader({
description, description,
className, className,
children, children,
actions,
}: PageHeaderProps) { }: PageHeaderProps) {
return ( return (
<div className={cn('space-y-1', className)}> <div className={cn('col md:row gap-2', className)}>
<h1 className="text-2xl font-semibold">{title}</h1> <div className={'space-y-1 flex-1'}>
{description && ( <h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-muted-foreground font-medium">{description}</p> {description && (
)} <p className="text-muted-foreground font-medium">{description}</p>
{children} )}
{children}
</div>
<div className="row gap-2">{actions}</div>
</div> </div>
); );
} }

View File

@@ -1,18 +1,21 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model'; import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series'; import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda'; import { last } from 'ramda';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
Area, Area,
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
Customized,
Legend, Legend,
Line,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
@@ -20,6 +23,7 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { SolidToDashedGradient } from '../common/linear-gradient'; import { SolidToDashedGradient } from '../common/linear-gradient';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
@@ -63,15 +67,20 @@ export function Chart({ data }: Props) {
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series); const rechartData = useRechartDataModel(series);
// great care should be taken when computing lastIntervalPercent let dotIndex = undefined;
// the expression below works for data.length - 1 equal intervals if (range === 'today') {
// but if there are numeric x values in a "linear" axis, the formula // Find closest index based on times
// should be updated to use those values dotIndex = rechartData.findIndex((item) => {
const lastIntervalPercent = return isSameHour(item.date, new Date());
((rechartData.length - 2) * 100) / (rechartData.length - 1); });
}
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date(); const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
const useDashedLastLine = (() => { const useDashedLastLine = (() => {
if (range === 'today') {
return true;
}
if (interval === 'hour') { if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date()); return isSameHour(lastSerieDataItem, new Date());
} }
@@ -84,6 +93,10 @@ export function Chart({ data }: Props) {
return isSameMonth(lastSerieDataItem, new Date()); return isSameMonth(lastSerieDataItem, new Date());
} }
if (interval === 'week') {
return isSameWeek(lastSerieDataItem, new Date());
}
return false; return false;
})(); })();
@@ -114,11 +127,34 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
useDashedStroke({
dotIndex,
});
return ( return (
<> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={rechartData}> <ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
horizontal={true} horizontal={true}
@@ -143,60 +179,62 @@ export function Chart({ data }: Props) {
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
<Legend content={<CustomLegend />} /> <Legend content={<CustomLegend />} />
<Tooltip content={<ReportChartTooltip />} /> <Tooltip content={<ReportChartTooltip.Tooltip />} />
{series.map((serie) => { {series.map((serie) => {
const color = getChartColor(serie.index); const color = getChartColor(serie.index);
return ( return (
<React.Fragment key={serie.id}> <defs key={`defs-${serie.id}`}>
<defs> <linearGradient
<linearGradient id={`color${color}`}
id={`color${color}`} x1="0"
x1="0" y1="0"
y1="0" x2="0"
x2="0" y2="1"
y2="1" >
> <stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop offset="0%" stopColor={color} stopOpacity={0.8} /> <stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
<stop </linearGradient>
offset={'100%'} </defs>
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
{useDashedLastLine && (
<SolidToDashedGradient
percentage={lastIntervalPercent}
baseColor={color}
id={`stroke${color}`}
/>
)}
</defs>
<Area
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
stroke={useDashedLastLine ? `url(#stroke${color})` : color}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
/>
{previous && (
<Area
stackId="2"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
)}
</React.Fragment>
); );
})} })}
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={serie.id}
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
/>
);
})}
{previous &&
series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={`${serie.id}:prev`}
stackId="2"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
);
})}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -207,6 +245,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -1,149 +1,148 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model'; import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model';
import type { IToolTipProps } from '@/types'; import React from 'react';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import React, { useEffect, useState } from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import {
format,
isSameDay,
isSameHour,
isSameMinute,
isSameMonth,
isSameWeek,
} from 'date-fns';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
import { PreviousDiffIndicator } from './previous-diff-indicator'; import { PreviousDiffIndicator } from './previous-diff-indicator';
import { SerieIcon } from './serie-icon'; import { SerieIcon } from './serie-icon';
import { SerieName } from './serie-name'; import { SerieName } from './serie-name';
type ReportLineChartTooltipProps = IToolTipProps<{ const getMatchingReferences = (
value: number; interval: IInterval,
name: string; references: RouterOutputs['reference']['getChartReferences'],
dataKey: string; date: Date,
payload: Record<string, unknown>; ) => {
}>; return references.filter((reference) => {
if (interval === 'minute') {
return isSameMinute(reference.date, date);
}
if (interval === 'hour') {
return isSameHour(reference.date, date);
}
if (interval === 'day') {
return isSameDay(reference.date, date);
}
if (interval === 'week') {
return isSameWeek(reference.date, date);
}
if (interval === 'month') {
return isSameMonth(reference.date, date);
}
return false;
});
};
export function ReportChartTooltip({ type Context = {
active, references?: RouterOutputs['reference']['getChartReferences'];
payload, };
}: ReportLineChartTooltipProps) { type Data = {
const { date: string;
report: { interval, unit }, timestamp: number;
} = useReportChartContext(); [key: `${string}:count`]: number;
const formatDate = useFormatDateInterval(interval); [key: `${string}:payload`]: IRechartPayloadItem;
const number = useNumber(); };
const [position, setPosition] = useState<{ x: number; y: number } | null>( export const ReportChartTooltip = createChartTooltip<Data, Context>(
null, ({ context: { references }, data }) => {
); const {
report: { interval, unit },
} = useReportChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
const inactive = !active || !payload?.length; if (!data || data.length === 0) {
useEffect(() => { return null;
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.filter((item) => !item.name.includes(':noTooltip'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
} }
const tooltipWidth = 300; const firstItem = data[0];
const screenWidth = window.innerWidth; const matchingReferences = getMatchingReferences(
const newX = x; interval,
references ?? [],
new Date(firstItem.date),
);
if (newX + tooltipWidth > screenWidth) { // Get all payload items from the first data point
return screenWidth - tooltipWidth; const payloadItems = Object.keys(firstItem)
} .filter((key) => key.endsWith(':payload'))
return newX; .map(
}; (key) =>
firstItem[key as keyof typeof firstItem] as IRechartPayloadItem,
)
.filter((item) => item && typeof item === 'object' && 'id' in item);
return ( // Sort by count
<Portal.Portal const sorted = payloadItems.sort((a, b) => (b.count || 0) - (a.count || 0));
style={{ const limit = 3;
position: 'fixed', const visible = sorted.slice(0, limit);
top: position?.y, const hidden = sorted.slice(limit);
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return ( return (
<React.Fragment key={data.id}> <div className="flex min-w-[180px] flex-col gap-2">
{index === 0 && data.date && ( {visible.map((item, index) => (
<div className="flex justify-between gap-8"> <React.Fragment key={item.id}>
<div>{formatDate(new Date(data.date))}</div> {index === 0 && item.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(item.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={item.names} />
<SerieName name={item.names} />
</div> </div>
)} <div className="flex justify-between gap-8 font-mono font-medium">
<div className="flex gap-2"> <div className="row gap-1">
<div {number.formatWithUnit(item.count, unit)}
className="w-[3px] rounded-full" {!!item.previous && (
style={{ background: data.color }} <span className="text-muted-foreground">
/> ({number.formatWithUnit(item.previous.value, unit)})
<div className="col flex-1 gap-1"> </span>
<div className="flex items-center gap-1"> )}
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
</div> </div>
<PreviousDiffIndicator {...item.previous} />
</div> </div>
</div> </div>
</React.Fragment> </div>
); </React.Fragment>
})} ))}
{hidden.length > 0 && ( {hidden.length > 0 && (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
and {hidden.length} more... and {hidden.length} more...
</div> </div>
)} )}
{matchingReferences.length > 0 && (
<>
<hr className="border-border" />
{matchingReferences.map((reference) => (
<div
key={reference.id}
className="row justify-between items-center"
>
<div className="font-medium text-sm">{reference.title}</div>
<div className="font-medium text-sm shrink-0 text-muted-foreground">
{format(reference.date, 'HH:mm')}
</div>
</div>
))}
</>
)}
</div> </div>
</Portal.Portal> );
); },
} );

View File

@@ -10,6 +10,8 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { pushModal } from '@/modals';
import { useCallback } from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
@@ -75,11 +77,22 @@ export function Chart({ data }: Props) {
}; };
}); });
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return ( return (
<TooltipProvider conversion={data} interval={interval}> <TooltipProvider conversion={data} interval={interval}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={rechartData}> <LineChart data={rechartData} onClick={handleChartClick}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
horizontal={true} horizontal={true}

View File

@@ -1,14 +1,18 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model'; import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import { useVisibleSeries } from '@/hooks/use-visible-series'; import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import React from 'react'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import { import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
@@ -43,9 +47,23 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
isEditMode, isEditMode,
report: { previous, interval }, report: { previous, interval, projectId, startDate, endDate, range },
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
} = useReportChartContext(); } = useReportChartContext();
const trpc = useTRPC();
const references = useQuery(
trpc.reference.getChartReferences.queryOptions(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
},
),
);
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series); const rechartData = useRechartDataModel(series);
const yAxisProps = useYAxisProps({ const yAxisProps = useYAxisProps({
@@ -55,17 +73,32 @@ export function Chart({ data }: Props) {
hide: hideXAxis, hide: hideXAxis,
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return ( return (
<> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={rechartData}> <BarChart data={rechartData} onClick={handleChartClick}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
vertical={false} vertical={false}
className="stroke-def-200" className="stroke-def-200"
/> />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} /> <Tooltip
content={<ReportChartTooltip.Tooltip />}
cursor={<BarHover />}
/>
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
<XAxis {...xAxisProps} scale={'auto'} type="category" /> <XAxis {...xAxisProps} scale={'auto'} type="category" />
{previous {previous
@@ -94,6 +127,21 @@ export function Chart({ data }: Props) {
/> />
); );
})} })}
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -104,6 +152,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -1,15 +1,15 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model'; import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series'; import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda'; import { last } from 'ramda';
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
Area,
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
Customized, Customized,
@@ -127,11 +127,23 @@ export function Chart({ data }: Props) {
const yAxisProps = useYAxisProps({ const yAxisProps = useYAxisProps({
hide: hideYAxis, hide: hideYAxis,
}); });
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return ( return (
<> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={rechartData}> <ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -166,7 +178,7 @@ export function Chart({ data }: Props) {
/> />
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
{series.length > 1 && <Legend content={<CustomLegend />} />} {series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip content={<ReportChartTooltip />} /> <Tooltip content={<ReportChartTooltip.Tooltip />} />
{/* {series.map((serie) => { {/* {series.map((serie) => {
const color = getChartColor(serie.index); const color = getChartColor(serie.index);
return ( return (
@@ -261,7 +273,9 @@ export function Chart({ data }: Props) {
} }
// Use for legend // Use for legend
fill={color} fill={color}
filter="url(#rainbow-line-glow)" filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
}
/> />
); );
})} })}
@@ -296,6 +310,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -25,6 +25,7 @@ import { GanttChartSquareIcon } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db'; import type { IServiceReport } from '@openpanel/db';
import EditReportName from '../report/edit-report-name';
interface ReportEditorProps { interface ReportEditorProps {
report: IServiceReport | null; report: IServiceReport | null;
@@ -50,66 +51,62 @@ export default function ReportEditor({
}; };
}, [initialReport, dispatch]); }, [initialReport, dispatch]);
useEffect(() => {
return bind(window, {
type: 'report-name-change',
listener: (event) => {
if (event instanceof CustomEvent && typeof event.detail === 'string') {
dispatch(setName(event.detail));
}
},
});
}, [dispatch]);
return ( return (
<Sheet> <Sheet>
<div className="grid grid-cols-2 gap-2 p-4 md:grid-cols-6"> <div>
<SheetTrigger asChild> <div className="p-4">
<div> <EditReportName />
<Button icon={GanttChartSquareIcon} variant="cta"> </div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>
<Button
icon={GanttChartSquareIcon}
variant="cta"
className="self-start"
>
Pick events Pick events
</Button> </Button>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType
className="min-w-0 flex-1"
onChange={(type) => {
dispatch(changeChartType(type));
}}
value={report.chartType}
/>
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0 flex-1"
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 row-start-1 text-right md:col-start-6">
<ReportSaveButton />
</div> </div>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType
className="min-w-0 flex-1"
onChange={(type) => {
dispatch(changeChartType(type));
}}
value={report.chartType}
/>
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0 flex-1"
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div> </div>
<div className="col-start-2 row-start-1 text-right md:col-start-6"> <div className="flex flex-col gap-4 p-4" id="report-editor">
<ReportSaveButton /> {report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left"> <SheetContent className="!max-w-lg" side="left">
<ReportSidebar /> <ReportSidebar />
</SheetContent> </SheetContent>

View File

@@ -6,7 +6,11 @@ import { SaveIcon } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useIsFetching, useMutation } from '@tanstack/react-query'; import {
useIsFetching,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router'; import { useParams } from '@tanstack/react-router';
import { resetDirty } from './reportSlice'; import { resetDirty } from './reportSlice';
@@ -22,13 +26,20 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
]; ];
const { reportId } = useParams({ strict: false }); const { reportId } = useParams({ strict: false });
const dispatch = useDispatch(); const dispatch = useDispatch();
const queryClient = useQueryClient();
const update = useMutation( const update = useMutation(
trpc.report.update.mutationOptions({ trpc.report.update.mutationOptions({
onSuccess() { onSuccess(res) {
dispatch(resetDirty()); dispatch(resetDirty());
toast('Success', { toast('Success', {
description: 'Report updated.', description: 'Report updated.',
}); });
queryClient.invalidateQueries(
trpc.report.list.queryFilter({
dashboardId: res.dashboardId,
projectId: res.projectId,
}),
);
}, },
onError: handleError, onError: handleError,
}), }),

View File

@@ -1,31 +1,35 @@
import { useDispatch, useSelector } from '@/redux';
import { PencilIcon } from 'lucide-react'; import { PencilIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Input } from '../ui/input';
import { setName } from './reportSlice';
type Props = { type Props = {
name?: string; name?: string;
}; };
const EditReportName = ({ name }: Props) => { const EditReportName = ({ name }: Props) => {
const reportName = useSelector((state) => state.report.name);
const dispatch = useDispatch();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name); const [newName, setNewName] = useState(reportName);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setNewName(reportName);
}, [reportName]);
const onSubmit = () => { const onSubmit = () => {
if (newName === name) { if (newName === name) {
return setIsEditing(false); return setIsEditing(false);
} }
if (newName === '') { if (!newName) {
setNewName(name); setNewName(reportName);
} }
window.dispatchEvent(
new CustomEvent('report-name-change', {
detail: newName === '' ? name : newName,
}),
);
setIsEditing(false); setIsEditing(false);
dispatch(setName(newName));
}; };
useEffect(() => { useEffect(() => {
@@ -36,11 +40,10 @@ const EditReportName = ({ name }: Props) => {
if (isEditing) { if (isEditing) {
return ( return (
<div className="flex"> <div className="flex h-8">
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
className="w-full rounded-md border border-input p-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={newName} value={newName}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -59,11 +62,14 @@ const EditReportName = ({ name }: Props) => {
return ( return (
<button <button
type="button" type="button"
className="flex cursor-pointer select-none items-center gap-2" className="flex cursor-pointer select-none items-center gap-2 text-xl font-medium h-8 group"
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
> >
{newName ?? 'Unnamed Report'} {newName ?? 'Unnamed Report'}
<PencilIcon size={16} /> <PencilIcon
size={16}
className="opacity-0 group-hover:opacity-100 transition-opacity"
/>
</button> </button>
); );
}; };

View File

@@ -117,7 +117,14 @@ export function ActionCTAButton() {
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentActionIndex((prevIndex) => (prevIndex + 1) % ACTIONS.length); setCurrentActionIndex((prevIndex) => {
const nextIndex = (prevIndex + 1) % ACTIONS.length;
if (nextIndex === 0 && prevIndex !== 0) {
clearInterval(interval);
return 0;
}
return nextIndex;
});
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);

View File

@@ -111,7 +111,7 @@ export function SidebarContainer({
{active ? <XIcon size={16} /> : <MenuIcon size={16} />} {active ? <XIcon size={16} /> : <MenuIcon size={16} />}
</Button> </Button>
</div> </div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4"> <div className="flex h-16 shrink-0 items-center gap-2 border-b border-border px-4">
<Link to="/"> <Link to="/">
<LogoSquare className="max-h-8" /> <LogoSquare className="max-h-8" />
</Link> </Link>
@@ -120,7 +120,6 @@ export function SidebarContainer({
projects={projects} projects={projects}
organizations={organizations} organizations={organizations}
/> />
<ProfileToggle />
</div> </div>
<div <div
className={cn([ className={cn([
@@ -131,7 +130,10 @@ export function SidebarContainer({
{children} {children}
<div className="mt-auto w-full pt-6"> <div className="mt-auto w-full pt-6">
<FeedbackButton /> <div className="row gap-2 justify-between">
<FeedbackButton />
<ProfileToggle />
</div>
{isSelfHosted && ( {isSelfHosted && (
<div className={cn('text-sm w-full text-left mt-2')}> <div className={cn('text-sm w-full text-left mt-2')}>
Self-hosted instance Self-hosted instance

View File

@@ -18,7 +18,11 @@ import { ModalContent, ModalHeader } from './Modal/Container';
type IForm = z.infer<typeof zCreateReference>; type IForm = z.infer<typeof zCreateReference>;
export default function AddReference() { interface AddReferenceProps {
datetime?: string;
}
export default function AddReference({ datetime }: AddReferenceProps = {}) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { register, handleSubmit, formState, control } = useForm<IForm>({ const { register, handleSubmit, formState, control } = useForm<IForm>({
@@ -27,7 +31,7 @@ export default function AddReference() {
title: '', title: '',
description: '', description: '',
projectId, projectId,
datetime: new Date().toISOString(), datetime: datetime || new Date().toISOString(),
}, },
}); });
@@ -52,7 +56,7 @@ export default function AddReference() {
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => mutation.mutate(values))} onSubmit={handleSubmit((values) => mutation.mutate(values))}
> >
<InputWithLabel label="Title" {...register('title')} /> <InputWithLabel label="Title" {...register('title')} autoFocus />
<InputWithLabel label="Description" {...register('description')} /> <InputWithLabel label="Description" {...register('description')} />
<Controller <Controller
control={control} control={control}

View File

@@ -20,7 +20,7 @@ export default function Confirm({
return ( return (
<ModalContent> <ModalContent>
<ModalHeader title={title} /> <ModalHeader title={title} />
<p className="text-lg -mt-2">{text}</p> <p className="text-lg -mt-2 leading-normal">{text}</p>
<ButtonContainer> <ButtonContainer>
<Button <Button
variant="outline" variant="outline"

View File

@@ -108,6 +108,12 @@ function Component() {
title="Dashboards" title="Dashboards"
description="Access all your dashboards here" description="Access all your dashboards here"
className="mb-8" className="mb-8"
actions={
<Button icon={PlusIcon} onClick={() => pushModal('AddDashboard')}>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
}
/> />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{dashboards.map((item) => { {dashboards.map((item) => {

View File

@@ -460,63 +460,63 @@ function Component() {
return ( return (
<PageContainer> <PageContainer>
<div className="row mb-4 items-center justify-between"> <PageHeader
<PageHeader title={dashboard.name}
title={dashboard.name} description="View and manage your reports"
description="View and manage your reports" className="mb-0"
className="mb-0" actions={
/> <>
<div className="flex items-center justify-end gap-2"> <OverviewRange />
<OverviewRange /> <OverviewInterval />
<OverviewInterval /> <LinkButton
<LinkButton from={Route.fullPath}
from={Route.fullPath} to={'/$organizationId/$projectId/reports'}
to={'/$organizationId/$projectId/reports'} icon={PlusIcon}
icon={PlusIcon} >
> <span className="max-sm:hidden">Create report</span>
<span className="max-sm:hidden">Create report</span> <span className="sm:hidden">Report</span>
<span className="sm:hidden">Report</span> </LinkButton>
</LinkButton> <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button variant="outline">
<Button variant="outline"> <MoreHorizontal />
<MoreHorizontal /> </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuGroup>
<DropdownMenuGroup> <DropdownMenuItem
<DropdownMenuItem onClick={() =>
onClick={() => showConfirm({
showConfirm({ title: 'Reset layout',
title: 'Reset layout', text: 'Are you sure you want to reset the layout to default? This will clear all custom positioning and sizing.',
text: 'Are you sure you want to reset the layout to default? This will clear all custom positioning and sizing.', onConfirm: () =>
onConfirm: () => resetLayout.mutate({ dashboardId, projectId }),
resetLayout.mutate({ dashboardId, projectId }), })
}) }
} >
> <RotateCcw className="mr-2 size-4" />
<RotateCcw className="mr-2 size-4" /> Reset layout
Reset layout </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem variant="destructive"
variant="destructive" onClick={() =>
onClick={() => showConfirm({
showConfirm({ title: 'Delete dashboard',
title: 'Delete dashboard', text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!',
text: 'Are you sure you want to delete this dashboard? All your reports will be deleted!', onConfirm: () =>
onConfirm: () => dashboardDeletion.mutate({ id: dashboardId }),
dashboardDeletion.mutate({ id: dashboardId }), })
}) }
} >
> <TrashIcon className="mr-2 size-4" />
<TrashIcon className="mr-2 size-4" /> Delete dashboard
Delete dashboard </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuGroup>
</DropdownMenuGroup> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> </>
</div> }
</div> />
{reports.length === 0 ? ( {reports.length === 0 ? (
<FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}> <FullPageEmptyState title="No reports" icon={LayoutPanelTopIcon}>