diff --git a/web/src/api/asset_interfaces/metric/interfaces.ts b/web/src/api/asset_interfaces/metric/interfaces.ts index ec6a33940..47b61f71d 100644 --- a/web/src/api/asset_interfaces/metric/interfaces.ts +++ b/web/src/api/asset_interfaces/metric/interfaces.ts @@ -7,6 +7,7 @@ export type BusterMetric = { title: string; version_number: number; description: string | null; + file_name: string; time_frame: string; dataset_id: string; data_source_id: string; diff --git a/web/src/app/app/_components/Popups/SaveResetFilePopup.tsx b/web/src/app/app/_components/Popups/SaveResetFilePopup.tsx new file mode 100644 index 000000000..1158dcb8b --- /dev/null +++ b/web/src/app/app/_components/Popups/SaveResetFilePopup.tsx @@ -0,0 +1,53 @@ +import { PopupContainer, PopupSplitter } from '@/components/popup'; +import React from 'react'; +import { Text } from '@/components/text'; +import { Button } from 'antd'; +import { AppMaterialIcons } from '@/components'; +import { createStyles } from 'antd-style'; + +export const SaveResetFilePopup: React.FC<{ + open: boolean; + onReset: () => void; + onSave: () => void; +}> = React.memo(({ open, onReset, onSave }) => { + return ( + + + + ); +}); + +const SplitterContent: React.FC<{ + onReset: () => void; + onSave: () => void; +}> = React.memo(({ onReset, onSave }) => { + const { styles, cx } = useStyles(); + + return ( +
+
+ + Unsaved changes +
+ + + +
+ + +
+
+ ); +}); + +SaveResetFilePopup.displayName = 'SaveResetFilePopup'; +SplitterContent.displayName = 'SplitterContent'; +const useStyles = createStyles(({ css, token }) => ({ + icon: css` + color: ${token.colorIcon}; + ` +})); diff --git a/web/src/app/app/_controllers/MetricController/MetricViewFile/MetricViewFile.tsx b/web/src/app/app/_controllers/MetricController/MetricViewFile/MetricViewFile.tsx index 57a4ba48e..9f7c7720a 100644 --- a/web/src/app/app/_controllers/MetricController/MetricViewFile/MetricViewFile.tsx +++ b/web/src/app/app/_controllers/MetricController/MetricViewFile/MetricViewFile.tsx @@ -1,8 +1,44 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import type { MetricViewProps } from '../config'; +import { CodeCard } from '@/components/card'; +import { useBusterMetricIndividual, useBusterMetricsContextSelector } from '@/context/Metrics'; +import { useMemoizedFn } from 'ahooks'; +import { SaveResetFilePopup } from '@appComponents/Popups/SaveResetFilePopup'; +import { useBusterNotifications } from '@/context/BusterNotifications'; export const MetricViewFile: React.FC = React.memo(({ metricId }) => { - return
MetricViewFile
; + const { metric } = useBusterMetricIndividual({ metricId }); + const { openSuccessMessage } = useBusterNotifications(); + const onUpdateMetric = useBusterMetricsContextSelector((x) => x.onUpdateMetric); + + const { file: fileProp, file_name } = metric; + + const [file, setFile] = React.useState(fileProp); + + const showPopup = file !== fileProp && !!file; + + const onResetFile = useMemoizedFn(() => { + setFile(fileProp); + }); + + const onSaveFile = useMemoizedFn(async () => { + await onUpdateMetric({ + file + }); + openSuccessMessage(`${file_name} saved`); + }); + + useEffect(() => { + setFile(fileProp); + }, [fileProp]); + + return ( +
+ + + +
+ ); }); MetricViewFile.displayName = 'MetricViewFile'; diff --git a/web/src/components/card/CodeCard.tsx b/web/src/components/card/CodeCard.tsx new file mode 100644 index 000000000..e46bab9ef --- /dev/null +++ b/web/src/components/card/CodeCard.tsx @@ -0,0 +1,120 @@ +import { createStyles } from 'antd-style'; +import React from 'react'; +import { Text } from '@/components/text'; +import { AppCodeEditor } from '../inputs/AppCodeEditor'; +import { Button } from 'antd'; +import { AppMaterialIcons } from '../icons'; +import { useMemoizedFn } from 'ahooks'; +import { useBusterNotifications } from '@/context/BusterNotifications'; + +export const CodeCard: React.FC<{ + code: string; + language: string; + fileName: string; + className?: string; + bodyClassName?: string; + buttons?: React.ReactNode | true; + onChange?: (code: string) => void; + readOnly?: boolean; +}> = ({ + code, + language = 'yml', + fileName, + className = 'h-full overflow-hidden', + bodyClassName = 'h-full', + buttons = true, + onChange, + readOnly = false +}) => { + const { styles, cx } = useStyles(); + + const ShownButtons = buttons === true ? : buttons; + + return ( +
+
+ {fileName} + + {ShownButtons} +
+
+ +
+
+ ); +}; + +const CardButtons: React.FC<{ + fileName: string; + code: string; +}> = React.memo(({ fileName, code }) => { + const { openInfoMessage, openErrorMessage } = useBusterNotifications(); + + const handleCopyCode = useMemoizedFn(async () => { + try { + await navigator.clipboard.writeText(code); + openInfoMessage('Code copied to clipboard'); + } catch (error) { + openErrorMessage('Failed to copy code'); + } + }); + + const handleDownload = useMemoizedFn(() => { + try { + const blob = new Blob([code], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + openErrorMessage('Failed to download file'); + } + }); + + return ( +
+
+ ); +}); +CardButtons.displayName = 'CardButtons'; + +const useStyles = createStyles(({ token, css }) => ({ + container: css` + border-radius: ${token.borderRadius}px; + border: 0.5px solid ${token.colorBorder}; + `, + containerHeader: css` + background: ${token.controlItemBgActive}; + border-bottom: 0.5px solid ${token.colorBorder}; + height: 32px; + `, + containerBody: css` + background: ${token.colorBgBase}; + ` +})); diff --git a/web/src/components/card/index.ts b/web/src/components/card/index.ts index e655c9d50..fe94d19b8 100644 --- a/web/src/components/card/index.ts +++ b/web/src/components/card/index.ts @@ -1 +1,2 @@ export * from './ItemContainer'; +export * from './CodeCard'; diff --git a/web/src/components/list/BusterList/BusterListSelectedOptionPopup/BusterListSelectedOptionPopupContainer.tsx b/web/src/components/list/BusterList/BusterListSelectedOptionPopup/BusterListSelectedOptionPopupContainer.tsx index f9f5a4dc8..1a13d4f5f 100644 --- a/web/src/components/list/BusterList/BusterListSelectedOptionPopup/BusterListSelectedOptionPopupContainer.tsx +++ b/web/src/components/list/BusterList/BusterListSelectedOptionPopup/BusterListSelectedOptionPopupContainer.tsx @@ -4,6 +4,7 @@ import { useAntToken } from '@/styles/useAntToken'; import { createStyles } from 'antd-style'; import React from 'react'; import { AnimatePresence, motion } from 'framer-motion'; +import { PopupContainer, PopupSplitter } from '@/components/popup/PopupContainer'; export const BusterListSelectedOptionPopupContainer: React.FC<{ selectedRowKeys: string[]; @@ -16,44 +17,17 @@ export const BusterListSelectedOptionPopupContainer: React.FC<{ const show = showProp ?? selectedRowKeys.length > 0; return ( - - {show && ( - -
- {show && ( -
- + +
+ - {buttons.length > 0 && ( -
- )} + {buttons.length > 0 && } - {buttons.map((button, index) => ( - {button} - ))} -
- )} -
- - )} - + {buttons.map((button, index) => ( + {button} + ))} +
+ ); }; diff --git a/web/src/components/popup/PopupContainer.tsx b/web/src/components/popup/PopupContainer.tsx new file mode 100644 index 000000000..3a26bc955 --- /dev/null +++ b/web/src/components/popup/PopupContainer.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { createStyles } from 'antd-style'; + +export const PopupContainer: React.FC<{ + show: boolean; + children: React.ReactNode; + secondaryChildren?: React.ReactNode; +}> = ({ show, children, secondaryChildren }) => { + const { styles, cx } = useStyles(); + + return ( + + {show && ( + +
{show && <>{children}}
+
+ )} +
+ ); +}; + +export const PopupSplitter: React.FC<{}> = ({}) => { + const { styles, cx } = useStyles(); + return
; +}; + +const useStyles = createStyles(({ css, token }) => ({ + container: css` + background-color: ${token.colorBgContainer}; + border-radius: ${token.borderRadius}px; + box-shadow: ${token.boxShadowSecondary}; + padding: ${token.paddingXS}px; + `, + splitter: css` + background-color: ${token.colorSplit}; + height: ${token.controlHeight - 7}px; + width: 0.5px; + ` +})); diff --git a/web/src/components/popup/index.ts b/web/src/components/popup/index.ts new file mode 100644 index 000000000..5000baded --- /dev/null +++ b/web/src/components/popup/index.ts @@ -0,0 +1 @@ +export * from './PopupContainer'; diff --git a/web/src/context/Metrics/MOCK_METRIC.ts b/web/src/context/Metrics/MOCK_METRIC.ts index 37e2db0ab..bf370db63 100644 --- a/web/src/context/Metrics/MOCK_METRIC.ts +++ b/web/src/context/Metrics/MOCK_METRIC.ts @@ -62,6 +62,7 @@ export const MOCK_METRIC: IBusterMetric = { id: '123', title: 'Mock Metric', version_number: 1, + file_name: 'mock_metric.yml', description: faker.lorem.sentence(33), data_source_id: '6840fa04-c0d7-4e0e-8d3d-ea9190d93874', time_frame: '1d', @@ -77,7 +78,48 @@ export const MOCK_METRIC: IBusterMetric = { status: VerificationStatus.notRequested, evaluation_score: 'Moderate', evaluation_summary: faker.lorem.sentence(33), - file: '', + file: ` +metric: + name: sales_performance + description: Monthly sales performance by product + source: sales_database + refresh_interval: daily + +dimensions: + - name: date + type: date + format: YYYY-MM-DD + - name: product + type: string + - name: region + type: string + +measures: + - name: sales_amount + type: decimal + aggregation: sum + - name: units_sold + type: integer + aggregation: sum + +filters: + - field: date + operator: between + value: [2024-01-01, 2024-12-31] + - field: region + operator: in + value: [North, South, East, West] + +joins: + - name: product_details + type: left + on: product_id + +sorting: + - field: sales_amount + direction: desc + +limit: 1000`, created_at: '', updated_at: '', sent_by_id: '', diff --git a/web/src/context/Metrics/config.ts b/web/src/context/Metrics/config.ts index 9f8815176..56b22f366 100644 --- a/web/src/context/Metrics/config.ts +++ b/web/src/context/Metrics/config.ts @@ -16,14 +16,16 @@ export const defaultIBusterMetric: Required = { fetchedAt: 0, code: null, feedback: null, - dataset_id: null, + dataset_id: '', dataset_name: null, error: null, data_metadata: null, status: VerificationStatus.notRequested, evaluation_score: 'Moderate', evaluation_summary: '', + file_name: '', file: '', + data_source_id: '', created_at: '', updated_at: '', sent_by_id: '', diff --git a/web/src/context/Metrics/helpers/saveToServerHelpers.ts b/web/src/context/Metrics/helpers/saveToServerHelpers.ts index ac0625878..d32690822 100644 --- a/web/src/context/Metrics/helpers/saveToServerHelpers.ts +++ b/web/src/context/Metrics/helpers/saveToServerHelpers.ts @@ -22,7 +22,7 @@ const DEFAULT_COLUMN_SETTINGS_ENTRIES = Object.entries(DEFAULT_COLUMN_SETTINGS); const DEFAULT_COLUMN_LABEL_FORMATS_ENTRIES = Object.entries(DEFAULT_COLUMN_LABEL_FORMAT); const getChangedTopLevelMessageValues = (newMetric: IBusterMetric, oldMetric: IBusterMetric) => { - return getChangedValues(oldMetric, newMetric, ['title', 'feedback', 'status', 'code']); + return getChangedValues(oldMetric, newMetric, ['title', 'feedback', 'status', 'code', 'file']); }; const keySpecificHandlers: Partial any>> = { diff --git a/web/src/context/SQL/useSQLProvider.tsx b/web/src/context/SQL/useSQLProvider.tsx index 5b2f87075..57438a0c1 100644 --- a/web/src/context/SQL/useSQLProvider.tsx +++ b/web/src/context/SQL/useSQLProvider.tsx @@ -100,7 +100,7 @@ export const useSQLProvider = () => { return result; } catch (error) { - console.log(error); + // } } );