diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index fb31cb0a8..70ccb14eb 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -4,6 +4,7 @@ import type { Preview } from '@storybook/react'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BusterStyleProvider } from '../src/context/BusterStyles/BusterStyles'; +import { BusterAssetsProvider } from '../src/context/Assets/BusterAssetsProvider'; import '../src/styles/styles.scss'; initialize(); @@ -41,7 +42,9 @@ const preview: Preview = { return ( - + + + ); diff --git a/web/src/api/buster_rest/dashboards/queryRequests.ts b/web/src/api/buster_rest/dashboards/queryRequests.ts index 8e470388e..1902ab7b2 100644 --- a/web/src/api/buster_rest/dashboards/queryRequests.ts +++ b/web/src/api/buster_rest/dashboards/queryRequests.ts @@ -58,7 +58,7 @@ const useGetDashboardAndInitializeMetrics = () => { }); return useMemoizedFn(async (id: string) => { - const { password } = getAssetPassword(id); + const { password } = getAssetPassword?.(id) || {}; return dashboardsGetDashboard({ id: id!, password }).then((data) => { initializeMetrics(data.metrics); @@ -72,6 +72,7 @@ export const useGetDashboard = ( select?: (data: BusterDashboardResponse) => TData ) => { const queryFn = useGetDashboardAndInitializeMetrics(); + const queryClient = useQueryClient(); return useQuery({ ...dashboardQueryKeys.dashboardGetDashboard(id!), diff --git a/web/src/api/buster_rest/dashboards/requests.ts b/web/src/api/buster_rest/dashboards/requests.ts index e693cc17c..f4cabec9e 100644 --- a/web/src/api/buster_rest/dashboards/requests.ts +++ b/web/src/api/buster_rest/dashboards/requests.ts @@ -2,14 +2,12 @@ import mainApi from '@/api/buster_rest/instances'; import type { DashboardsListRequest, DashboardCreateRequest, - DashboardUpdateRequest, - DashboardSubscribeRequest + DashboardUpdateRequest } from '@/api/request_interfaces/dashboards/interfaces'; import type { BusterDashboardListItem, BusterDashboardResponse } from '@/api/asset_interfaces/dashboard'; -import { ShareRole } from '@/api/asset_interfaces'; import { ShareDeleteRequest, SharePostRequest, @@ -22,7 +20,15 @@ export const dashboardsGetList = async (params: DashboardsListRequest) => { .then((res) => res.data); }; -export const dashboardsGetDashboard = async ({ id, password }: DashboardSubscribeRequest) => { +export const dashboardsGetDashboard = async ({ + id, + password +}: { + /** The unique identifier of the dashboard */ + id: string; + /** Optional password for accessing protected dashboards */ + password?: string; +}) => { return await mainApi .get(`/dashboards/${id}`, { params: { password } }) .then((res) => res.data); diff --git a/web/src/api/request_interfaces/dashboards/interfaces.ts b/web/src/api/request_interfaces/dashboards/interfaces.ts index f6cd1d59c..1331db9c8 100644 --- a/web/src/api/request_interfaces/dashboards/interfaces.ts +++ b/web/src/api/request_interfaces/dashboards/interfaces.ts @@ -15,16 +15,6 @@ export interface DashboardsListRequest { only_my_dashboards?: boolean; } -/** - * Interface for subscribing to a dashboard - */ -export interface DashboardSubscribeRequest { - /** The unique identifier of the dashboard */ - id: string; - /** Optional password for accessing protected dashboards */ - password?: string; -} - /** * Interface for unsubscribing from a specific dashboard */ diff --git a/web/src/app/app/(settings_layout)/settings/(restricted-width)/datasources/(admin-restricted-space)/[datasourceId]/_DatasourceFormContent.tsx b/web/src/app/app/(settings_layout)/settings/(restricted-width)/datasources/(admin-restricted-space)/[datasourceId]/_DatasourceFormContent.tsx index 634af3d3b..0c46ec1fa 100644 --- a/web/src/app/app/(settings_layout)/settings/(restricted-width)/datasources/(admin-restricted-space)/[datasourceId]/_DatasourceFormContent.tsx +++ b/web/src/app/app/(settings_layout)/settings/(restricted-width)/datasources/(admin-restricted-space)/[datasourceId]/_DatasourceFormContent.tsx @@ -80,8 +80,7 @@ export const DataSourceFormContent: React.FC<{ route: BusterRoutes.SETTINGS_DATASOURCES_ID, datasourceId: res.id }); - }, - cancelButtonProps: { className: 'hidden!' } + } }); } } catch (error) { diff --git a/web/src/components/features/modal/AddToDashboardModal.stories.tsx b/web/src/components/features/modal/AddToDashboardModal.stories.tsx index de32d1de7..c824f9348 100644 --- a/web/src/components/features/modal/AddToDashboardModal.stories.tsx +++ b/web/src/components/features/modal/AddToDashboardModal.stories.tsx @@ -3,13 +3,16 @@ import { AddToDashboardModal } from './AddToDashboardModal'; import { http, HttpResponse } from 'msw'; import { fn } from '@storybook/test'; import { BASE_URL } from '@/api/buster_rest/config'; -import { BusterMetricListItem, VerificationStatus } from '@/api/asset_interfaces'; +import { BusterMetricListItem } from '@/api/asset_interfaces'; import { createMockListMetric } from '@/mocks/metric'; +import { generateMockDashboard } from '@/mocks/MOCK_DASHBOARD'; const mockMetrics: BusterMetricListItem[] = Array.from({ length: 100 }, (_, index) => createMockListMetric(`${index + 1}`) ); +const { response } = generateMockDashboard(3, 'dashboard-1'); + const meta = { title: 'Features/Modal/AddToDashboardModal', component: AddToDashboardModal, @@ -17,7 +20,10 @@ const meta = { layout: 'centered', msw: { handlers: [ - http.get(`${BASE_URL}/metrics`, () => { + http.get(`${BASE_URL}/dashboards/dashboard-1`, () => { + return HttpResponse.json(response); + }), + http.get(`${BASE_URL}/metrics?page_token=0&page_size=3000`, () => { return HttpResponse.json(mockMetrics); }) ] @@ -41,14 +47,5 @@ export const EmptyState: Story = { open: true, onClose: fn(), dashboardId: 'dashboard-1' - }, - parameters: { - msw: { - handlers: [ - http.get(`${BASE_URL}/metrics`, () => { - return HttpResponse.json([]); - }) - ] - } } }; diff --git a/web/src/components/features/modal/AddToDashboardModal.tsx b/web/src/components/features/modal/AddToDashboardModal.tsx index 5ee7d2ea9..e940ca027 100644 --- a/web/src/components/features/modal/AddToDashboardModal.tsx +++ b/web/src/components/features/modal/AddToDashboardModal.tsx @@ -1,86 +1,111 @@ import { useGetMetricsList } from '@/api/buster_rest/metrics'; -import { AppModal } from '@/components/ui/modal'; -import { useDebounceSearch } from '@/hooks'; -import React, { useState } from 'react'; -import { BusterList } from '@/components/ui/list'; -import { Input } from '@/components/ui/inputs'; +import { useDebounceSearch, useMemoizedFn } from '@/hooks'; +import React, { useLayoutEffect, useMemo, useState } from 'react'; +import { InputSelectModal, InputSelectModalProps } from '@/components/ui/modal/InputSelectModal'; +import { formatDate } from '@/lib'; +import { Button } from '@/components/ui/buttons'; +import { useGetDashboard } from '@/api/buster_rest/dashboards'; +import { useQueryClient } from '@tanstack/react-query'; export const AddToDashboardModal: React.FC<{ open: boolean; onClose: () => void; dashboardId: string; }> = React.memo(({ open, onClose, dashboardId }) => { + const { data: dashboard, isFetched: isFetchedDashboard } = useGetDashboard(dashboardId); const { data: metrics, isFetched: isFetchedMetrics } = useGetMetricsList({}); const [selectedMetrics, setSelectedMetrics] = useState([]); - const { filteredItems, handleSearchChange } = useDebounceSearch({ - items: metrics || [], - searchPredicate: (item, searchText) => { - return item.title.toLowerCase().includes(searchText.toLowerCase()); - } - }); - - const columns = [ + const columns: InputSelectModalProps['columns'] = [ { - title: 'Metric', - dataIndex: 'title', - width: 300 + title: 'Title', + dataIndex: 'title' + }, + { + title: 'Last edited', + dataIndex: 'last_edited', + width: 132, + render: (value: string, x) => { + return formatDate({ + date: value, + format: 'lll' + }); + } } ]; - const rows = filteredItems.map((metric) => ({ - id: metric.id, - data: { - title: metric.title - } - })); + const rows = useMemo(() => { + return metrics.map((metric) => ({ + id: metric.id, + data: metric + })); + }, [metrics.length]); - const handleAddMetrics = async () => { + const handleAddMetrics = useMemoizedFn(async () => { // TODO: Implement the API call to add metrics to dashboard - console.log('Adding metrics:', selectedMetrics); onClose(); - }; + }); + + const onSelectChange = useMemoizedFn((items: string[]) => { + setSelectedMetrics(items); + }); + + const isSelectedChanged = useMemo(() => { + const originalIds = Object.keys(dashboard?.metrics || {}); + const newIds = selectedMetrics; + return originalIds.length !== newIds.length || originalIds.some((id) => !newIds.includes(id)); + }, [dashboard?.metrics, selectedMetrics]); + + const emptyState = useMemo(() => { + if (!isFetchedMetrics || !isFetchedDashboard) { + return 'Loading metrics...'; + } + if (rows.length === 0) { + return 'No metrics found'; + } + return undefined; + }, [isFetchedMetrics, isFetchedDashboard, rows]); + + const footer: NonNullable = useMemo(() => { + return { + left: + selectedMetrics.length > 0 ? ( + + ) : undefined, + secondaryButton: { + text: 'Cancel', + onClick: onClose + }, + primaryButton: { + text: `Update metrics`, + onClick: handleAddMetrics, + disabled: !isSelectedChanged, + tooltip: isSelectedChanged + ? `Adding ${selectedMetrics.length} metrics` + : 'No changes to update' + } + }; + }, [selectedMetrics.length, isSelectedChanged, handleAddMetrics]); + + useLayoutEffect(() => { + if (isFetchedDashboard) { + const metrics = Object.keys(dashboard?.metrics || {}); + setSelectedMetrics(metrics); + } + }, [isFetchedDashboard, dashboard?.metrics]); return ( - -
- handleSearchChange(e.target.value)} - /> -
- -
-
-
+ columns={columns} + rows={rows} + onSelectChange={onSelectChange} + selectedRowKeys={selectedMetrics} + footer={footer} + emptyState={emptyState} + /> ); }); diff --git a/web/src/components/ui/modal/Modal.stories.tsx b/web/src/components/ui/modal/AppModal.stories.tsx similarity index 99% rename from web/src/components/ui/modal/Modal.stories.tsx rename to web/src/components/ui/modal/AppModal.stories.tsx index 0ef99bd4b..831a4a3ab 100644 --- a/web/src/components/ui/modal/Modal.stories.tsx +++ b/web/src/components/ui/modal/AppModal.stories.tsx @@ -6,6 +6,7 @@ import { Button } from '../buttons/Button'; import React from 'react'; import { ModalProps } from './AppModal'; import { fn } from '@storybook/test'; + const meta: Meta = { title: 'UI/Modal/AppModal', component: AppModal, diff --git a/web/src/components/ui/modal/AppModal.tsx b/web/src/components/ui/modal/AppModal.tsx index 31e9df489..0af4d85f6 100644 --- a/web/src/components/ui/modal/AppModal.tsx +++ b/web/src/components/ui/modal/AppModal.tsx @@ -22,7 +22,7 @@ export interface ModalProps { left?: React.ReactNode; primaryButton: { text: string; - onClick: () => Promise | (() => void); + onClick: (() => Promise) | (() => void); variant?: ButtonProps['variant']; loading?: boolean; disabled?: boolean; @@ -46,7 +46,6 @@ export interface ModalProps { export const AppModal: React.FC = React.memo( ({ open, onClose, footer, header, width = 600, className, style, children }) => { const [isLoadingPrimaryButton, setIsLoadingPrimaryButton] = useState(false); - const [isLoadingSecondaryButton, setIsLoadingSecondaryButton] = useState(false); const onOpenChange = useMemoizedFn((open: boolean) => { if (!open) { onClose(); @@ -65,22 +64,21 @@ export const AppModal: React.FC = React.memo( const onPrimaryButtonClickPreflight = useMemoizedFn(async () => { setIsLoadingPrimaryButton(true); await footer.primaryButton.onClick(); - setIsLoadingPrimaryButton(false); }); return ( -
+
{header && ( - + {header.title && {header.title}} {header.description && {header.description}} )} -
{children}
+ {children}
{footer && ( diff --git a/web/src/components/ui/modal/BorderedModal.stories.tsx b/web/src/components/ui/modal/BorderedModal.stories.tsx new file mode 100644 index 000000000..f6f0e4e39 --- /dev/null +++ b/web/src/components/ui/modal/BorderedModal.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BorderedModal } from './BorderedModal'; +import { useState } from 'react'; + +const meta: Meta = { + title: 'UI/Modal/ScrollableModal', + component: BorderedModal, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +// Wrapper component to handle state +const ScrollableModalWrapper = (args: any) => { + const [open, setOpen] = useState(true); + return ; +}; + +export const Basic: Story = { + render: (args) => , + args: { + header: { + title: 'Example Modal', + description: 'This is a basic example of the ScrollableModal component' + }, + children: ( +
+ {Array.from({ length: 20 }).map((_, i) => ( +

This is paragraph {i + 1} demonstrating scrollable content in the modal.

+ ))} +
+ ), + footer: { + primaryButton: { + text: 'Save Changes', + onClick: () => console.log('Primary button clicked') + }, + secondaryButton: { + text: 'Cancel', + onClick: () => console.log('Secondary button clicked'), + variant: 'ghost' + } + }, + width: 600 + } +}; + +export const WithCustomHeader: Story = { + render: (args) => , + args: { + header: ( +
+

Custom Header

+

+ This example shows how to use a custom header component +

+
+ ), + children: ( +
+

Modal content with custom header styling.

+

You can add any React node as the header content.

+
+ ), + footer: { + left: Footer left content, + primaryButton: { + text: 'Confirm', + onClick: () => console.log('Confirmed'), + variant: 'black' + }, + secondaryButton: { + text: 'Back', + onClick: () => console.log('Going back'), + variant: 'ghost' + } + }, + width: 500 + } +}; + +export const LoadingState: Story = { + render: (args) => , + args: { + header: { + title: 'Loading State Example', + description: 'This example shows the modal with loading state in buttons' + }, + children: ( +
+

Modal content with loading state buttons.

+
+ ), + footer: { + primaryButton: { + text: 'Submit', + onClick: () => new Promise((resolve) => setTimeout(resolve, 2000)), + loading: true + }, + secondaryButton: { + text: 'Cancel', + onClick: () => console.log('Cancelled'), + variant: 'ghost', + disabled: true + } + }, + width: 400 + } +}; diff --git a/web/src/components/ui/modal/BorderedModal.tsx b/web/src/components/ui/modal/BorderedModal.tsx new file mode 100644 index 000000000..d2d0c1234 --- /dev/null +++ b/web/src/components/ui/modal/BorderedModal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useMemoizedFn } from '@/hooks/useMemoizedFn'; +import { Button, ButtonProps } from '../buttons/Button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from './ModalBase'; +import React, { useState, ReactNode, useMemo } from 'react'; +import { cn } from '@/lib/classMerge'; +import { AppTooltip } from '../tooltip'; + +export interface BorderedModalProps { + children: React.ReactNode; + width?: number; + footer: { + left?: React.ReactNode; + primaryButton: { + text: string; + onClick: (() => Promise) | (() => void); + variant?: ButtonProps['variant']; + loading?: boolean; + disabled?: boolean; + tooltip?: string; + }; + secondaryButton?: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; + loading?: boolean; + disabled?: boolean; + }; + }; + header?: + | { + title: string; + description?: string; + } + | ReactNode; + open: boolean; + onClose: () => void; + className?: string; + scrollAreaClassName?: string; +} + +export const BorderedModal = React.memo( + ({ + children, + width = 600, + footer, + header, + open, + onClose, + className = '' + }: BorderedModalProps) => { + const [isLoadingPrimaryButton, setIsLoadingPrimaryButton] = useState(false); + + const onPrimaryButtonClickPreflight = useMemoizedFn(async () => { + setIsLoadingPrimaryButton(true); + await footer.primaryButton.onClick(); + setIsLoadingPrimaryButton(false); + }); + + const onOpenChange = useMemoizedFn((open: boolean) => { + if (!open) { + onClose(); + } + }); + + const memoizedStyle = useMemo(() => { + return { + width: width, + maxWidth: width + }; + }, [width]); + + const headerIsTitleObject = isHeaderTitleObject(header); + + return ( + + + {header && ( + + {headerIsTitleObject ? ( + <> + {header.title} + {header.description} + + ) : ( + header + )} + + )} + + {children} + + {footer && ( + + {footer.left && footer.left} +
+ {footer.secondaryButton && ( + + )} + + + + + +
+
+ )} +
+
+ ); + } +); + +const isHeaderTitleObject = ( + header: + | { + title: string; + description?: string; + } + | ReactNode +): header is { + title: string; + description?: string; +} => { + return typeof header === 'object' && header !== null && 'title' in header; +}; diff --git a/web/src/components/ui/modal/ConfirmModal.tsx b/web/src/components/ui/modal/ConfirmModal.tsx index 8275000de..ef49d91b3 100644 --- a/web/src/components/ui/modal/ConfirmModal.tsx +++ b/web/src/components/ui/modal/ConfirmModal.tsx @@ -5,7 +5,7 @@ export interface ConfirmProps { title: string | React.ReactNode; description?: string | React.ReactNode; content: string | React.ReactNode; - onOk: () => Promise | (() => void); + onOk: (() => Promise) | (() => void); onCancel?: () => Promise | void; width?: number; cancelButtonProps?: { diff --git a/web/src/components/ui/modal/InputScrollableModal.stories.tsx b/web/src/components/ui/modal/InputScrollableModal.stories.tsx new file mode 100644 index 000000000..f0f7119e6 --- /dev/null +++ b/web/src/components/ui/modal/InputScrollableModal.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { InputSelectModal } from './InputSelectModal'; +import React from 'react'; +import { fn } from '@storybook/test'; +import { faker } from '@faker-js/faker'; +import { useSet } from '@/hooks'; + +const meta: Meta = { + title: 'UI/Modal/InputSelectModal', + component: InputSelectModal, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [selectedItems, { replace }] = useSet(); + + const onSelectChange = (items: string[]) => { + replace(items); + }; + + return ( + + ); + }, + args: { + inputPlaceholder: 'Search items...', + footer: { + primaryButton: { + text: 'Confirm', + onClick: fn() + }, + secondaryButton: { + text: 'Cancel', + onClick: fn(), + variant: 'ghost' + } + }, + columns: [ + { + title: 'Name', + dataIndex: 'name' + }, + { + title: 'Email', + dataIndex: 'email' + } + ], + selectedRowKeys: [], + rows: Array.from({ length: 3000 }, () => ({ + id: faker.string.uuid(), + data: { name: faker.person.fullName(), email: faker.internet.email() } + })) + } +}; diff --git a/web/src/components/ui/modal/InputSelectModal.tsx b/web/src/components/ui/modal/InputSelectModal.tsx new file mode 100644 index 000000000..ef934889b --- /dev/null +++ b/web/src/components/ui/modal/InputSelectModal.tsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react'; +import { BorderedModal, BorderedModalProps } from './BorderedModal'; +import { Input } from '../inputs/Input'; +import { BusterList, BusterListProps } from '../list/BusterList'; +import { useDebounceSearch } from '@/hooks'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { DialogTitle } from '@radix-ui/react-dialog'; +import { Text } from '../typography'; + +export interface InputSelectModalProps extends Omit { + inputPlaceholder?: string; + columns: NonNullable; + rows: NonNullable; + emptyState: BusterListProps['emptyState']; + onSelectChange: NonNullable; + selectedRowKeys: NonNullable; + showHeader?: NonNullable; +} + +export const InputSelectModal = React.memo( + ({ + inputPlaceholder = 'Search...', + columns, + rows, + emptyState, + onSelectChange, + selectedRowKeys, + showHeader = true, + ...props + }: InputSelectModalProps) => { + const { filteredItems, handleSearchChange, searchText } = useDebounceSearch({ + items: rows, + searchPredicate: (item, searchText) => { + const values = Object.values(item.data || {}); + return values.some((value) => + value.toString().toLowerCase().includes(searchText.toLowerCase()) + ); + } + }); + + return ( + + + + Input Modal + + + } + {...props}> +
+ emptyState || No items found, + [emptyState] + )} + showHeader={showHeader} + selectedRowKeys={selectedRowKeys} + useRowClickSelectChange={true} + hideLastRowBorder + /> +
+
+ ); + } +); +InputSelectModal.displayName = 'InputScrollableModal'; + +const InputSelecteHeader: React.FC<{ + inputPlaceholder: string; + searchText: string; + handleSearchChange: (searchText: string) => void; +}> = ({ inputPlaceholder, searchText, handleSearchChange }) => { + return ( +
+ handleSearchChange(e.target.value)} + placeholder={inputPlaceholder} + variant={'ghost'} + type="text" + size={'tall'} + autoFocus + /> +
+ ); +}; diff --git a/web/src/components/ui/modal/ModalBase.tsx b/web/src/components/ui/modal/ModalBase.tsx index d9f38957c..39e30803e 100644 --- a/web/src/components/ui/modal/ModalBase.tsx +++ b/web/src/components/ui/modal/ModalBase.tsx @@ -33,8 +33,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.memo( React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + showClose?: boolean; + } + >(({ className, children, showClose = true, ...props }, ref) => ( {children} - -