From 5f6ce012e2beff05db53201bf21051e1773fccd7 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 14 Mar 2025 18:14:48 -0600 Subject: [PATCH] add additional metric three dot settings --- web/package-lock.json | 7 ++ web/package.json | 1 + .../api/asset_interfaces/metric/defaults.ts | 3 +- .../MetricViewChart/MetricViewChart.tsx | 1 + .../MetricViewChartContent.tsx | 13 ++- .../MetricViewChart/config.ts | 1 + .../MetricThreeDotMenu.tsx | 109 +++++++++++++----- web/src/lib/exportUtils.tsx | 45 ++------ web/src/mocks/metric.ts | 1 + 9 files changed, 112 insertions(+), 69 deletions(-) create mode 100644 web/src/controllers/MetricController/MetricViewChart/config.ts diff --git a/web/package-lock.json b/web/package-lock.json index 8fc46fdb4..74259301b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -42,6 +42,7 @@ "chartjs-plugin-deferred": "^2.0.0", "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", + "dom-to-image": "^2.6.0", "email-validator": "^2.0.4", "font-color-contrast": "^11.1.0", "framer-motion": "^12.5.0", @@ -10342,6 +10343,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/dom-to-image": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", + "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==", + "license": "MIT" + }, "node_modules/domain-browser": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.23.0.tgz", diff --git a/web/package.json b/web/package.json index a05667898..7b875bed2 100644 --- a/web/package.json +++ b/web/package.json @@ -50,6 +50,7 @@ "chartjs-plugin-deferred": "^2.0.0", "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", + "dom-to-image": "^2.6.0", "email-validator": "^2.0.4", "font-color-contrast": "^11.1.0", "framer-motion": "^12.5.0", diff --git a/web/src/api/asset_interfaces/metric/defaults.ts b/web/src/api/asset_interfaces/metric/defaults.ts index 2ac5c7a49..caaf6826d 100644 --- a/web/src/api/asset_interfaces/metric/defaults.ts +++ b/web/src/api/asset_interfaces/metric/defaults.ts @@ -174,7 +174,8 @@ export const DEFAULT_IBUSTER_METRIC: Required = { public_expiry_date: null, public_enabled_by: null, publicly_accessible: false, - public_password: null + public_password: null, + versions: [] }; export const DEFAULT_BUSTER_METRIC_LIST_ITEM: Required = { diff --git a/web/src/controllers/MetricController/MetricViewChart/MetricViewChart.tsx b/web/src/controllers/MetricController/MetricViewChart/MetricViewChart.tsx index 0dbfa9baf..bc0d78e88 100644 --- a/web/src/controllers/MetricController/MetricViewChart/MetricViewChart.tsx +++ b/web/src/controllers/MetricController/MetricViewChart/MetricViewChart.tsx @@ -47,6 +47,7 @@ export const MetricViewChart: React.FC<{ metricId: string }> = React.memo(({ met dataMetadata={metricData?.data_metadata} fetchedData={isFetchedMetricData} errorMessage={metricDataError?.message} + metricId={metricId} /> diff --git a/web/src/controllers/MetricController/MetricViewChart/MetricViewChartContent.tsx b/web/src/controllers/MetricController/MetricViewChart/MetricViewChartContent.tsx index d5a7dd8d2..204506157 100644 --- a/web/src/controllers/MetricController/MetricViewChart/MetricViewChartContent.tsx +++ b/web/src/controllers/MetricController/MetricViewChart/MetricViewChartContent.tsx @@ -3,6 +3,7 @@ import { ChartType } from '@/api/asset_interfaces/metric/charts'; import { BusterChart } from '@/components/ui/charts'; import { cn } from '@/lib/classMerge'; import React, { useMemo } from 'react'; +import { METRIC_CHART_CONTAINER_ID } from './config'; interface MetricViewChartContentProps { className?: string; @@ -11,10 +12,19 @@ interface MetricViewChartContentProps { dataMetadata: DataMetadata | undefined; fetchedData: boolean; errorMessage: string | null | undefined; + metricId: string; } export const MetricViewChartContent: React.FC = React.memo( - ({ className, chartConfig, metricData = null, dataMetadata, fetchedData, errorMessage }) => { + ({ + className, + chartConfig, + metricData = null, + dataMetadata, + fetchedData, + errorMessage, + metricId + }) => { const columnMetadata = dataMetadata?.column_metadata; const isTable = chartConfig?.selectedChartType === ChartType.Table; @@ -30,6 +40,7 @@ export const MetricViewChartContent: React.FC = Rea error={errorMessage || undefined} data={metricData} columnMetadata={columnMetadata} + id={METRIC_CHART_CONTAINER_ID(metricId)} {...chartConfig} /> diff --git a/web/src/controllers/MetricController/MetricViewChart/config.ts b/web/src/controllers/MetricController/MetricViewChart/config.ts new file mode 100644 index 000000000..4fa7e82ad --- /dev/null +++ b/web/src/controllers/MetricController/MetricViewChart/config.ts @@ -0,0 +1 @@ +export const METRIC_CHART_CONTAINER_ID = (metricId: string) => `metric-chart-container-${metricId}`; diff --git a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx index 893c9b964..8cf62a345 100644 --- a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx +++ b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx @@ -41,7 +41,8 @@ import { ShareAssetType, VerificationStatus } from '@/api/asset_interfaces/share import { useStatusDropdownContent } from '@/components/features/metrics/StatusBadgeIndicator/StatusDropdownContent'; import { StatusBadgeIndicator } from '@/components/features/metrics/StatusBadgeIndicator'; import { useFavoriteStar } from '@/components/features/list/FavoriteStar'; -import { exportJSONToCSV } from '@/lib/exportUtils'; +import { downloadElementToImage, exportElementToImage, exportJSONToCSV } from '@/lib/exportUtils'; +import { METRIC_CHART_CONTAINER_ID } from '@/controllers/MetricController/MetricViewChart/config'; export const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string }) => { const { mutateAsync: deleteMetric, isPending: isDeletingMetric } = useDeleteMetric(); @@ -57,6 +58,9 @@ export const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string } const resultsViewMenu = useResultsViewSelectMenu(); const sqlEditorMenu = useSQLEditorSelectMenu(); const downloadCSVMenu = useDownloadCSVSelectMenu({ metricId }); + const downloadPNGMenu = useDownloadPNGSelectMenu({ metricId }); + const deleteMetricMenu = useDeleteMetricSelectMenu({ metricId }); + const renameMetricMenu = useRenameMetricSelectMenu({ metricId }); const items: DropdownItems = useMemo( () => [ @@ -77,37 +81,16 @@ export const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string } sqlEditorMenu, { type: 'divider' }, downloadCSVMenu, - // { - // label: 'Download as PNG', - // value: 'download-png', - // icon: , - // onClick: () => { - // console.log('download png'); - // } - // }, + downloadPNGMenu, { type: 'divider' }, - { - label: 'Rename metric', - value: 'rename', - icon: , - onClick: () => { - console.log('rename'); - } - }, - { - label: 'Delete metric', - value: 'delete', - icon: , - loading: isDeletingMetric, - onClick: async () => { - await deleteMetric({ ids: [metricId] }); - openSuccessMessage('Metric deleted'); - onSetSelectedFile(null); - } - } + renameMetricMenu, + deleteMetricMenu ], [ dashboardSelectMenu, + deleteMetricMenu, + downloadCSVMenu, + downloadPNGMenu, isDeletingMetric, metricId, openSuccessMessage, @@ -366,6 +349,7 @@ const useDownloadCSVSelectMenu = ({ metricId }: { metricId: string }) => { label: 'Download as CSV', value: 'download-csv', icon: , + loading: isDownloading, onClick: async () => { const data = metricData?.data; if (data && title) { @@ -375,6 +359,73 @@ const useDownloadCSVSelectMenu = ({ metricId }: { metricId: string }) => { } } }), - [metricData, title] + [metricData, isDownloading, title] + ); +}; + +const useDownloadPNGSelectMenu = ({ metricId }: { metricId: string }) => { + const { openSuccessMessage, openErrorMessage } = useBusterNotifications(); + const { data: title } = useGetMetric(metricId, (x) => x.title); + const { data: selectedChartType } = useGetMetric( + metricId, + (x) => x.chart_config?.selectedChartType + ); + + const canDownload = selectedChartType && selectedChartType !== 'table'; + + return useMemo( + () => ({ + label: 'Download as PNG', + value: 'download-png', + disabled: !canDownload, + icon: , + onClick: async () => { + const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement; + if (node) { + try { + return await downloadElementToImage(node, `${title}.png`); + } catch (error) { + console.error(error); + } + } + + openErrorMessage('Failed to download PNG'); + } + }), + [canDownload] + ); +}; + +const useDeleteMetricSelectMenu = ({ metricId }: { metricId: string }) => { + const { mutateAsync: deleteMetric } = useDeleteMetric(); + + return useMemo( + () => ({ + label: 'Delete metric', + value: 'delete-metric', + icon: , + onClick: async () => { + await deleteMetric({ ids: [metricId] }); + } + }), + [metricId] + ); +}; + +const useRenameMetricSelectMenu = ({ metricId }: { metricId: string }) => { + const { mutateAsync: updateMetric } = useUpdateMetric(); + + return useMemo( + () => ({ + label: 'Rename metric', + value: 'rename-metric', + icon: , + onClick: async () => { + console.log('rename'); + alert('TODO: Implement rename metric'); + // await updateMetric({ id: metricId, title: 'New title' }); + } + }), + [metricId] ); }; diff --git a/web/src/lib/exportUtils.tsx b/web/src/lib/exportUtils.tsx index 3d8862e19..ee2fb6b22 100644 --- a/web/src/lib/exportUtils.tsx +++ b/web/src/lib/exportUtils.tsx @@ -1,3 +1,5 @@ +'use client'; + import { timeout } from './timeout'; export async function exportJSONToCSV( @@ -38,44 +40,6 @@ export async function exportJSONToCSV( downloadFile(`${fileName}.csv`, blob); } -export async function exportToCsv(gridElement: HTMLDivElement, fileName: string) { - const { head, body, foot } = await getGridContent(gridElement); - const content = [...head, ...body, ...foot] - .map((cells) => cells.map(serialiseCellValue).join(',')) - .join('\n'); - - downloadFile(fileName, new Blob([content], { type: 'text/csv;charset=utf-8;' })); -} - -async function getGridContent(gridElement: HTMLDivElement) { - const grid = gridElement as HTMLDivElement; - const hasrdgClass = grid.classList.contains('rdg'); - if (!hasrdgClass) { - throw new Error('Element is not a valid ReactDataGrid instance'); - } - return { - head: getRows('.rdg-header-row'), - body: getRows('.rdg-row:not(.rdg-summary-row)'), - foot: getRows('.rdg-summary-row') - }; - - function getRows(selector: string) { - return Array.from(grid.querySelectorAll(selector)).map((gridRow) => { - return Array.from(gridRow.querySelectorAll('.rdg-cell')).map( - (gridCell) => gridCell.innerText - ); - }); - } -} - -function serialiseCellValue(value: unknown) { - if (typeof value === 'string') { - const formattedValue = value.replace(/"/g, '""'); - return formattedValue.includes(',') ? `"${formattedValue}"` : formattedValue; - } - return value; -} - function downloadFile(fileName: string, data: Blob) { const downloadLink = document.createElement('a'); downloadLink.download = fileName; @@ -95,6 +59,11 @@ export async function exportElementToImage(element: HTMLElement) { return dataUrl; } +export async function downloadElementToImage(element: HTMLElement, fileName: string) { + const imageData = await exportElementToImage(element); + downloadImageData(imageData, fileName); +} + export async function downloadImageData(imageData: string, fileName: string) { const link = document.createElement('a'); link.href = imageData; diff --git a/web/src/mocks/metric.ts b/web/src/mocks/metric.ts index 3dc9551ca..a7484ea55 100644 --- a/web/src/mocks/metric.ts +++ b/web/src/mocks/metric.ts @@ -91,6 +91,7 @@ export const createMockMetric = (id: string): IBusterMetric => { data_metadata: dataMetadata, status: VerificationStatus.NOT_REQUESTED, evaluation_score: 'Moderate', + versions: [], evaluation_summary: faker.lorem.sentence(33), file: ` metric: