Add 3 dot menu for report

This commit is contained in:
Nate Kelley 2025-08-06 22:18:17 -06:00
parent ac818aff47
commit 81bddd5216
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
17 changed files with 344 additions and 178 deletions

View File

@ -7,10 +7,10 @@ import { useContext, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { cn } from '@/lib/classMerge';
import { Hand } from '../icons';
import { SortableItemContext } from './_BusterSortableItemDragContainer';
import { BusterResizeableGrid } from './BusterResizeableGrid';
import { MIN_ROW_HEIGHT } from './helpers';
import type { BusterResizeableGridRow } from './interfaces';
import { SortableItemContext } from './SortableItemContext';
const meta: Meta<typeof BusterResizeableGrid> = {
title: 'UI/Grid/BusterResizeableGrid',

View File

@ -199,7 +199,7 @@ function Gutter({ children, className, ...props }: React.ComponentProps<'div'>)
{...props}
className={cn(
'slate-gutterLeft',
'absolute top-0 z-50 flex h-full -translate-x-full cursor-text hover:opacity-100 sm:opacity-0',
'absolute top-0 z-50 flex -translate-x-full cursor-text hover:opacity-100 sm:opacity-0',
getPluginByType(editor, element.type)?.node.isContainer
? 'group-hover/container:opacity-100'
: 'group-hover:opacity-100',

View File

@ -86,8 +86,8 @@ const MetricResizeContainer: React.FC<PropsWithChildren> = ({ children }) => {
ref={ref}
contentEditable={false}
className={cn(
'group relative m-0 my-1.5 w-full cursor-default',
isSelected && 'bg-item-hover/50 ring-ring rounded ring-2 ring-offset-4'
'group relative m-0 my-1.5 w-full cursor-default transition-all',
isSelected && 'bg-item-hover/10 ring-ring rounded ring-2 ring-offset-4'
)}>
<Resizable
align={align}

View File

@ -51,10 +51,7 @@ export const MetricEmbedPlaceholder: React.FC = () => {
<div
onClick={onOpenAddMetricModal}
className={cn(
'bg-muted hover:bg-primary/10 flex cursor-pointer items-center rounded-sm p-3 pr-9 select-none',
{
'shadow-md': focused && selected
}
'bg-muted hover:bg-primary/10 flex cursor-pointer items-center rounded-sm p-3 pr-9 select-none'
)}
contentEditable={false}>
<div className="text-muted-foreground/80 relative mr-3 flex [&_svg]:size-6">
@ -134,3 +131,5 @@ const MemoizedAddMetricModal = React.memo(
);
}
);
MemoizedAddMetricModal.displayName = 'MemoizedAddMetricModal';

View File

@ -1,8 +1,15 @@
import React, { useMemo } from 'react';
import type { DropdownItem, DropdownItems } from '@/components/ui/dropdown';
import { Trash } from '@/components/ui/icons';
import { Code, SquareChartPen, Table, Trash } from '@/components/ui/icons';
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
import { ASSET_ICONS } from '@/components/features/config/assetIcons';
import { ArrowUpRight } from '@/components/ui/icons';
import { useEditorRef, useElement, type PlateEditor } from 'platejs/react';
import type { TElement } from 'platejs';
import {
useDownloadMetricDataCSV,
useDownloadPNGSelectMenu,
useRenameMetricOnPage
} from '@/context/Metrics/metricDropdownItems';
export const useMetricContentThreeDotMenuItems = ({
metricId,
@ -17,6 +24,9 @@ export const useMetricContentThreeDotMenuItems = ({
reportVersionNumber: number | undefined;
reportId: string;
}): DropdownItems => {
const editor = useEditorRef();
const element = useElement();
const openChartItem = useOpenChartItem({
reportId,
metricId,
@ -24,8 +34,42 @@ export const useMetricContentThreeDotMenuItems = ({
reportVersionNumber,
metricVersionNumber
});
const removeFromReportItem = useRemoveFromReportItem({
editor,
element
});
const navigateToMetricItem = useNavigatetoMetricItem({
reportId,
metricId,
chatId,
reportVersionNumber,
metricVersionNumber
});
const downloadCSV = useDownloadMetricDataCSV({ metricId, metricVersionNumber });
const downloadPNG = useDownloadPNGSelectMenu({ metricId, metricVersionNumber });
const renameMetric = useRenameMetricOnPage({ metricId, metricVersionNumber });
return [openChartItem, { type: 'divider' }];
return useMemo(
() => [
openChartItem,
removeFromReportItem,
{ type: 'divider' },
...navigateToMetricItem,
{ type: 'divider' },
downloadCSV,
downloadPNG,
{ type: 'divider' },
renameMetric
],
[
openChartItem,
removeFromReportItem,
navigateToMetricItem,
downloadCSV,
downloadPNG,
renameMetric
]
);
};
const useOpenChartItem = ({
@ -54,20 +98,20 @@ const useOpenChartItem = ({
() => ({
value: 'open-chart',
label: 'Open chart',
icon: <ASSET_ICONS.metrics />,
icon: <ArrowUpRight />,
link: route,
linkIcon: 'arrow-external'
linkIcon: 'arrow-right'
}),
[route]
);
};
const useRemoveFromReportItem = ({
reportId,
metricId
editor,
element
}: {
reportId: string;
metricId: string;
editor: PlateEditor;
element: TElement;
}): DropdownItem => {
return useMemo(
() => ({
@ -75,9 +119,56 @@ const useRemoveFromReportItem = ({
label: 'Remove from report',
icon: <Trash />,
onClick: () => {
console.log('remove from report');
const path = editor.api.findPath(element);
editor.tf.removeNodes({ at: path });
}
}),
[]
);
};
const useNavigatetoMetricItem = ({
reportId,
metricId,
chatId,
reportVersionNumber,
metricVersionNumber
}: {
reportId: string;
metricId: string;
metricVersionNumber: number | undefined;
chatId: string | undefined;
reportVersionNumber: number | undefined;
}): DropdownItem[] => {
return useMemo(() => {
const baseParams = {
assetId: metricId,
type: 'metric' as const,
reportVersionNumber,
metricVersionNumber,
reportId,
chatId
};
const editChartRoute = assetParamsToRoute({
...baseParams,
page: 'chart'
});
const resultsChartRoute = assetParamsToRoute({
...baseParams,
page: 'results'
});
const sqlChartRoute = assetParamsToRoute({
...baseParams,
page: 'sql'
});
return [
{ value: 'edit-chart', label: 'Edit chart', icon: <SquareChartPen />, link: editChartRoute },
{ value: 'results-chart', label: 'Results chart', icon: <Table />, link: resultsChartRoute },
{ value: 'sql-chart', label: 'SQL chart', icon: <Code />, link: sqlChartRoute }
];
}, [reportId, metricId, chatId, reportVersionNumber, metricVersionNumber]);
};

View File

@ -1,16 +1,6 @@
import { type PluginConfig, createTSlatePlugin } from 'platejs';
export type BannerConfig = PluginConfig<
'banner',
//api
{},
//options
{},
//selectors
{},
//transforms
{}
>;
export type BannerConfig = PluginConfig<'banner'>;
export const BannerPlugin = createTSlatePlugin<BannerConfig>({
key: 'banner', // unique plugin key

View File

@ -25,6 +25,50 @@ const countCharactersInNodes = (nodes: (TNode | Descendant)[]): number => {
return count;
};
const CharacterCounterElement = ({
attributes,
children,
...props
}: CharacterElementCounterProps) => {
const { getOptions, element } = props;
const options = getOptions();
const selected = useSelected();
const { maxLength, showWarning, warningThreshold } = options;
// Get the character length of only this component's content
const characterLength = useMemo(() => {
return countCharactersInNodes(element.children);
}, [element.children]);
// Calculate warning state
const warningLength = maxLength * warningThreshold;
const isOverWarning = characterLength >= warningLength;
const isOverLimit = characterLength > maxLength;
return (
<PlateElement
className={cn(
'rounded-md bg-purple-100 p-2 text-black',
selected && 'ring-ring rounded bg-red-200 ring-2 ring-offset-2'
)}
attributes={{
...attributes,
'data-plate-open-context-menu': true
}}
{...props}>
<div className="mb-2 text-sm" contentEditable={false}>
Character count: {characterLength} / {maxLength}
{showWarning && isOverWarning && (
<span className={`ml-2 ${isOverLimit ? 'text-red-600' : 'text-yellow-600'}`}>
{isOverLimit ? '⚠️ Limit exceeded!' : '⚠️ Approaching limit'}
</span>
)}
</div>
{children}
</PlateElement>
);
};
export const CharacterCounterPlugin = createPlatePlugin({
key: 'characterCounter',
options: {
@ -80,45 +124,7 @@ export const CharacterCounterPlugin = createPlatePlugin({
}
},
node: {
component: ({ attributes, children, ...props }: CharacterElementCounterProps) => {
const { getOptions, element } = props;
const options = getOptions();
const selected = useSelected();
const { maxLength, showWarning, warningThreshold } = options;
// Get the character length of only this component's content
const characterLength = useMemo(() => {
return countCharactersInNodes(element.children);
}, [element.children]);
// Calculate warning state
const warningLength = maxLength * warningThreshold;
const isOverWarning = characterLength >= warningLength;
const isOverLimit = characterLength > maxLength;
return (
<PlateElement
className={cn(
'rounded-md bg-purple-100 p-2 text-black',
selected && 'ring-ring rounded bg-red-200 ring-2 ring-offset-2'
)}
attributes={{
...attributes,
'data-plate-open-context-menu': true
}}
{...props}>
<div className="mb-2 text-sm" contentEditable={false}>
Character count: {characterLength} / {maxLength}
{showWarning && isOverWarning && (
<span className={`ml-2 ${isOverLimit ? 'text-red-600' : 'text-yellow-600'}`}>
{isOverLimit ? '⚠️ Limit exceeded!' : '⚠️ Approaching limit'}
</span>
)}
</div>
{children}
</PlateElement>
);
},
component: CharacterCounterElement,
isElement: true
}
});

View File

@ -8,6 +8,7 @@ export const insertMetric = (editor: PlateEditor, options?: InsertNodesOptions)
{
type: CUSTOM_KEYS.metric,
metricId: '',
metricVersionNumber: undefined,
children: [{ text: '' }]
},
{ select: true, ...options }

View File

@ -7,6 +7,7 @@ export type MetricPluginOptions = {
openMetricModal: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type MetricPluginApi = {
// the methods are defined in the extendApi function
};

View File

@ -0,0 +1,3 @@
export * from './useDownloadMetricDataCSV';
export * from './useDownloadMetricDataPNG';
export * from './useRenameMetricOnPage';

View File

@ -0,0 +1,37 @@
import { useGetMetric, useGetMetricData } from '@/api/buster_rest/metrics';
import { Download4 } from '@/components/ui/icons';
import { exportJSONToCSV } from '@/lib/exportUtils';
import { useMemo, useState } from 'react';
export const useDownloadMetricDataCSV = ({
metricId,
metricVersionNumber
}: {
metricId: string;
metricVersionNumber: number | undefined;
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const { data: metricData } = useGetMetricData(
{ id: metricId, versionNumber: metricVersionNumber },
{ enabled: false }
);
const { data: name } = useGetMetric({ id: metricId }, { select: (x) => x.name });
return useMemo(
() => ({
label: 'Download as CSV',
value: 'download-csv',
icon: <Download4 />,
loading: isDownloading,
onClick: async () => {
const data = metricData?.data;
if (data && name) {
setIsDownloading(true);
await exportJSONToCSV(data, name);
setIsDownloading(false);
}
}
}),
[metricData, isDownloading, name]
);
};

View File

@ -0,0 +1,48 @@
import { useGetMetric } from '@/api/buster_rest/metrics';
import { SquareChart } from '@/components/ui/icons';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { METRIC_CHART_CONTAINER_ID } from '@/controllers/MetricController/MetricViewChart/config';
import { downloadElementToImage } from '@/lib/exportUtils';
import { useMemo } from 'react';
export const useDownloadPNGSelectMenu = ({
metricId,
metricVersionNumber
}: {
metricId: string;
metricVersionNumber: number | undefined;
}) => {
const { openErrorMessage } = useBusterNotifications();
const { data: name } = useGetMetric(
{ id: metricId, versionNumber: metricVersionNumber },
{ select: (x) => x.name }
);
const { data: selectedChartType } = useGetMetric(
{ id: metricId },
{ select: (x) => x.chart_config?.selectedChartType }
);
const canDownload = selectedChartType && selectedChartType !== 'table';
return useMemo(
() => ({
label: 'Download as PNG',
value: 'download-png',
disabled: !canDownload,
icon: <SquareChart />,
onClick: async () => {
const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement;
if (node) {
try {
return await downloadElementToImage(node, `${name}.png`);
} catch (error) {
console.error(error);
}
}
openErrorMessage('Failed to download PNG');
}
}),
[canDownload, metricId, name, openErrorMessage]
);
};

View File

@ -0,0 +1,68 @@
import { Pencil } from '@/components/ui/icons';
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
import { METRIC_CHART_TITLE_INPUT_ID } from '@/controllers/MetricController/MetricViewChart/MetricViewChartHeader';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout/ChatLayoutContext';
import { assetParamsToRoute } from '@/lib/assets';
import { ensureElementExists } from '@/lib/element';
import { timeout } from '@/lib/timeout';
import { useMemo } from 'react';
export const useRenameMetricOnPage = ({
metricId,
metricVersionNumber
}: {
metricId: string;
metricVersionNumber: number | undefined;
}) => {
const onSetFileView = useChatLayoutContextSelector((x) => x.onSetFileView);
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
const { chatId, dashboardId, reportId, dashboardVersionNumber, reportVersionNumber } =
useChatLayoutContextSelector((x) => ({
chatId: x.chatId,
dashboardId: x.dashboardId,
reportId: x.reportId,
dashboardVersionNumber: x.dashboardVersionNumber,
reportVersionNumber: x.reportVersionNumber
}));
return useMemo(
() => ({
label: 'Rename metric',
value: 'rename-metric',
icon: <Pencil />,
onClick: async () => {
const route = assetParamsToRoute({
type: 'metric',
assetId: metricId,
chatId,
dashboardId,
reportId,
dashboardVersionNumber,
reportVersionNumber,
metricVersionNumber,
page: 'chart'
});
await onChangePage(route);
await timeout(100);
const input = await ensureElementExists(
() => document.getElementById(METRIC_CHART_TITLE_INPUT_ID) as HTMLInputElement
);
if (input) {
input.focus();
input.select();
}
}
}),
[
onSetFileView,
metricId,
metricVersionNumber,
onChangePage,
chatId,
dashboardId,
reportId,
dashboardVersionNumber,
reportVersionNumber
]
);
};

View File

@ -7,7 +7,7 @@ import { assetParamsToRoute } from '@/lib/assets';
import { MetricCard } from '@/components/ui/metric';
import { useContext } from 'use-context-selector';
import { SortableItemContext } from '@/components/ui/grid/SortableItemContext';
import { metricCardThreeDotMenuItems } from './metricCardThreeDotMenuItems';
import { useMetricCardThreeDotMenuItems } from './metricCardThreeDotMenuItems';
const DashboardMetricItemBase: React.FC<{
metricId: string;
@ -63,9 +63,7 @@ const DashboardMetricItemBase: React.FC<{
});
}, [metricId, chatId, dashboardId]);
const threeDotMenuItems = useMemo(() => {
return metricCardThreeDotMenuItems({ dashboardId, metricId });
}, [dashboardId, metricId]);
const threeDotMenuItems = useMetricCardThreeDotMenuItems({ dashboardId, metricId });
const onInitialAnimationEndPreflight = useMemoizedFn(() => {
setInitialAnimationEnded(metricId);

View File

@ -1,18 +1,9 @@
'use client';
import React, { useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import { useRemoveMetricsFromDashboard } from '@/api/buster_rest/dashboards';
import { Button } from '@/components/ui/buttons';
import { Dropdown, type DropdownItems, type DropdownItem } from '@/components/ui/dropdown';
import {
DotsVertical,
Trash,
ShareRight,
PenSparkle,
SquareChartPen,
Code
} from '@/components/ui/icons';
import { cn } from '@/lib/utils';
import { type DropdownItems, type DropdownItem } from '@/components/ui/dropdown';
import { Trash, ShareRight, PenSparkle, SquareChartPen, Code } from '@/components/ui/icons';
import { ASSET_ICONS } from '@/components/features/config/assetIcons';
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
@ -28,7 +19,7 @@ import {
useMetricDrilldownItem
} from '@/components/features/metrics/ThreeDotMenu';
export const metricCardThreeDotMenuItems = ({
export const useMetricCardThreeDotMenuItems = ({
dashboardId,
metricId
}: {

View File

@ -60,6 +60,11 @@ import {
useVersionHistorySelectMenu,
useMetricDrilldownItem
} from '@/components/features/metrics/ThreeDotMenu';
import {
useDownloadMetricDataCSV,
useDownloadPNGSelectMenu,
useRenameMetricOnPage
} from '@/context/Metrics/metricDropdownItems';
export const ThreeDotMenuButton = React.memo(
({
@ -82,10 +87,19 @@ export const ThreeDotMenuButton = React.memo(
const editChartMenu = useEditChartSelectMenu();
const resultsViewMenu = useResultsViewSelectMenu({ chatId, metricId });
const sqlEditorMenu = useSQLEditorSelectMenu({ chatId, metricId });
const downloadCSVMenu = useDownloadCSVSelectMenu({ metricId });
const downloadPNGMenu = useDownloadPNGSelectMenu({ metricId });
const downloadCSVMenu = useDownloadMetricDataCSV({
metricId,
metricVersionNumber: versionNumber
});
const downloadPNGMenu = useDownloadPNGSelectMenu({
metricId,
metricVersionNumber: versionNumber
});
const deleteMetricMenu = useDeleteMetricSelectMenu({ metricId });
const renameMetricMenu = useRenameMetricSelectMenu({ metricId });
const renameMetricMenu = useRenameMetricOnPage({
metricId,
metricVersionNumber: versionNumber
});
const shareMenu = useShareMenuSelectMenu({ metricId });
const drilldownItem = useMetricDrilldownItem({ metricId });
@ -359,63 +373,6 @@ const useSQLEditorSelectMenu = ({
);
};
const useDownloadCSVSelectMenu = ({ metricId }: { metricId: string }) => {
const [isDownloading, setIsDownloading] = useState(false);
const { data: metricData } = useGetMetricData({ id: metricId }, { enabled: false });
const { data: name } = useGetMetric({ id: metricId }, { select: (x) => x.name });
return useMemo(
() => ({
label: 'Download as CSV',
value: 'download-csv',
icon: <Download4 />,
loading: isDownloading,
onClick: async () => {
const data = metricData?.data;
if (data && name) {
setIsDownloading(true);
await exportJSONToCSV(data, name);
setIsDownloading(false);
}
}
}),
[metricData, isDownloading, name]
);
};
const useDownloadPNGSelectMenu = ({ metricId }: { metricId: string }) => {
const { openErrorMessage } = useBusterNotifications();
const { data: name } = useGetMetric({ id: metricId }, { select: (x) => x.name });
const { data: selectedChartType } = useGetMetric(
{ id: metricId },
{ select: (x) => x.chart_config?.selectedChartType }
);
const canDownload = selectedChartType && selectedChartType !== 'table';
return useMemo(
() => ({
label: 'Download as PNG',
value: 'download-png',
disabled: !canDownload,
icon: <SquareChart />,
onClick: async () => {
const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement;
if (node) {
try {
return await downloadElementToImage(node, `${name}.png`);
} catch (error) {
console.error(error);
}
}
openErrorMessage('Failed to download PNG');
}
}),
[canDownload, metricId, name, openErrorMessage]
);
};
const useDeleteMetricSelectMenu = ({ metricId }: { metricId: string }) => {
const { mutateAsync: deleteMetric } = useDeleteMetric();
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
@ -434,39 +391,6 @@ const useDeleteMetricSelectMenu = ({ metricId }: { metricId: string }) => {
);
};
const useRenameMetricSelectMenu = ({ metricId }: { metricId: string }) => {
const onSetFileView = useChatLayoutContextSelector((x) => x.onSetFileView);
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
const chatId = useChatLayoutContextSelector((x) => x.chatId);
const dashboardId = useChatLayoutContextSelector((x) => x.dashboardId);
return useMemo(
() => ({
label: 'Rename metric',
value: 'rename-metric',
icon: <Pencil />,
onClick: async () => {
const route = assetParamsToRoute({
type: 'metric',
assetId: metricId,
chatId,
dashboardId,
page: 'chart'
});
await onChangePage(route);
await timeout(100);
const input = await ensureElementExists(
() => document.getElementById(METRIC_CHART_TITLE_INPUT_ID) as HTMLInputElement
);
if (input) {
input.focus();
input.select();
}
}
}),
[onSetFileView]
);
};
export const useShareMenuSelectMenu = ({ metricId }: { metricId: string }) => {
const { data: shareAssetConfig } = useGetMetric(
{ id: metricId },

View File

@ -252,6 +252,15 @@ export type BusterAppRoutesWithArgs = {
metricVersionNumber?: number;
reportVersionNumber?: number;
};
[BusterAppRoutes.APP_CHAT_ID_REPORT_ID_METRIC_ID_RESULTS]: {
route: BusterAppRoutes.APP_CHAT_ID_REPORT_ID_METRIC_ID_RESULTS;
chatId: string;
reportId: string;
metricId: string;
metricVersionNumber?: number;
reportVersionNumber?: number;
secondaryView?: MetricFileViewSecondary;
};
[BusterAppRoutes.APP_CHAT_ID_DATASET_ID]: {
route: BusterAppRoutes.APP_CHAT_ID_DATASET_ID;
chatId: string;