mirror of https://github.com/buster-so/buster.git
add metric modal updates
This commit is contained in:
parent
dec98bacfe
commit
a625a8ea64
|
@ -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 (
|
||||
<BusterStyleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
<BusterAssetsProvider>
|
||||
<Story />
|
||||
</BusterAssetsProvider>
|
||||
</QueryClientProvider>
|
||||
</BusterStyleProvider>
|
||||
);
|
||||
|
|
|
@ -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 = <TData = BusterDashboardResponse>(
|
|||
select?: (data: BusterDashboardResponse) => TData
|
||||
) => {
|
||||
const queryFn = useGetDashboardAndInitializeMetrics();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useQuery({
|
||||
...dashboardQueryKeys.dashboardGetDashboard(id!),
|
||||
|
|
|
@ -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<BusterDashboardResponse>(`/dashboards/${id}`, { params: { password } })
|
||||
.then((res) => res.data);
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -80,8 +80,7 @@ export const DataSourceFormContent: React.FC<{
|
|||
route: BusterRoutes.SETTINGS_DATASOURCES_ID,
|
||||
datasourceId: res.id
|
||||
});
|
||||
},
|
||||
cancelButtonProps: { className: 'hidden!' }
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -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([]);
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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<string[]>([]);
|
||||
|
||||
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<InputSelectModalProps['footer']> = useMemo(() => {
|
||||
return {
|
||||
left:
|
||||
selectedMetrics.length > 0 ? (
|
||||
<Button variant="ghost" onClick={() => setSelectedMetrics([])}>
|
||||
Clear selected
|
||||
</Button>
|
||||
) : 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 (
|
||||
<AppModal
|
||||
<InputSelectModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
header={{
|
||||
title: 'Add Metrics to Dashboard',
|
||||
description: 'Select metrics to add to your dashboard'
|
||||
}}
|
||||
footer={{
|
||||
primaryButton: {
|
||||
text: 'Add Selected Metrics',
|
||||
onClick: handleAddMetrics,
|
||||
disabled: selectedMetrics.length === 0
|
||||
},
|
||||
secondaryButton: {
|
||||
text: 'Cancel',
|
||||
onClick: onClose
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="Search metrics..."
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
<div className="h-[400px]">
|
||||
<BusterList
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
selectedRowKeys={selectedMetrics}
|
||||
onSelectChange={setSelectedMetrics}
|
||||
emptyState={
|
||||
!isFetchedMetrics
|
||||
? 'Loading metrics...'
|
||||
: filteredItems.length === 0
|
||||
? 'No metrics found'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
onSelectChange={onSelectChange}
|
||||
selectedRowKeys={selectedMetrics}
|
||||
footer={footer}
|
||||
emptyState={emptyState}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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<typeof AppModal> = {
|
||||
title: 'UI/Modal/AppModal',
|
||||
component: AppModal,
|
|
@ -22,7 +22,7 @@ export interface ModalProps {
|
|||
left?: React.ReactNode;
|
||||
primaryButton: {
|
||||
text: string;
|
||||
onClick: () => Promise<void> | (() => void);
|
||||
onClick: (() => Promise<void>) | (() => void);
|
||||
variant?: ButtonProps['variant'];
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
|
@ -46,7 +46,6 @@ export interface ModalProps {
|
|||
export const AppModal: React.FC<ModalProps> = 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<ModalProps> = React.memo(
|
|||
const onPrimaryButtonClickPreflight = useMemoizedFn(async () => {
|
||||
setIsLoadingPrimaryButton(true);
|
||||
await footer.primaryButton.onClick();
|
||||
|
||||
setIsLoadingPrimaryButton(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={className} style={memoizedStyle}>
|
||||
<div className="flex flex-col gap-4 overflow-hidden">
|
||||
<div className="flex flex-col gap-4 overflow-hidden p-6">
|
||||
{header && (
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogHeader className="">
|
||||
{header.title && <DialogTitle>{header.title}</DialogTitle>}
|
||||
{header.description && <DialogDescription>{header.description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="px-6"> {children}</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { BorderedModal } from './BorderedModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
const meta: Meta<typeof BorderedModal> = {
|
||||
title: 'UI/Modal/ScrollableModal',
|
||||
component: BorderedModal,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BorderedModal>;
|
||||
|
||||
// Wrapper component to handle state
|
||||
const ScrollableModalWrapper = (args: any) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return <BorderedModal {...args} open={open} onOpenChange={setOpen} />;
|
||||
};
|
||||
|
||||
export const Basic: Story = {
|
||||
render: (args) => <ScrollableModalWrapper {...args} />,
|
||||
args: {
|
||||
header: {
|
||||
title: 'Example Modal',
|
||||
description: 'This is a basic example of the ScrollableModal component'
|
||||
},
|
||||
children: (
|
||||
<div className="space-y-4 py-4">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<p key={i}>This is paragraph {i + 1} demonstrating scrollable content in the modal.</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
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) => <ScrollableModalWrapper {...args} />,
|
||||
args: {
|
||||
header: (
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h3 className="text-2xl font-bold tracking-tight">Custom Header</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This example shows how to use a custom header component
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="space-y-4 py-4">
|
||||
<p>Modal content with custom header styling.</p>
|
||||
<p>You can add any React node as the header content.</p>
|
||||
</div>
|
||||
),
|
||||
footer: {
|
||||
left: <span className="text-muted-foreground text-sm">Footer left content</span>,
|
||||
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) => <ScrollableModalWrapper {...args} />,
|
||||
args: {
|
||||
header: {
|
||||
title: 'Loading State Example',
|
||||
description: 'This example shows the modal with loading state in buttons'
|
||||
},
|
||||
children: (
|
||||
<div className="py-4">
|
||||
<p>Modal content with loading state buttons.</p>
|
||||
</div>
|
||||
),
|
||||
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
|
||||
}
|
||||
};
|
|
@ -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>) | (() => 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(className)}
|
||||
style={memoizedStyle}
|
||||
showClose={headerIsTitleObject}>
|
||||
{header && (
|
||||
<DialogHeader
|
||||
className={cn('border-b', headerIsTitleObject ? 'px-6 pt-6 pb-4' : 'space-y-0!')}>
|
||||
{headerIsTitleObject ? (
|
||||
<>
|
||||
<DialogTitle>{header.title}</DialogTitle>
|
||||
<DialogDescription>{header.description}</DialogDescription>
|
||||
</>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{footer && (
|
||||
<DialogFooter
|
||||
className={cn('flex items-center', footer.left ? 'justify-between' : 'justify-end')}>
|
||||
{footer.left && footer.left}
|
||||
<div className={cn('flex items-center space-x-2')}>
|
||||
{footer.secondaryButton && (
|
||||
<Button
|
||||
onClick={footer.secondaryButton.onClick}
|
||||
variant={footer.secondaryButton.variant ?? 'ghost'}
|
||||
loading={footer.secondaryButton.loading}
|
||||
disabled={footer.secondaryButton.disabled}>
|
||||
{footer.secondaryButton.text}
|
||||
</Button>
|
||||
)}
|
||||
<span>
|
||||
<AppTooltip
|
||||
title={footer.primaryButton.tooltip}
|
||||
sideOffset={8}
|
||||
delayDuration={400}>
|
||||
<Button
|
||||
onClick={onPrimaryButtonClickPreflight}
|
||||
variant={footer.primaryButton.variant ?? 'black'}
|
||||
loading={footer.primaryButton.loading ?? isLoadingPrimaryButton}
|
||||
disabled={footer.primaryButton.disabled}>
|
||||
{footer.primaryButton.text}
|
||||
</Button>
|
||||
</AppTooltip>
|
||||
</span>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const isHeaderTitleObject = (
|
||||
header:
|
||||
| {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
| ReactNode
|
||||
): header is {
|
||||
title: string;
|
||||
description?: string;
|
||||
} => {
|
||||
return typeof header === 'object' && header !== null && 'title' in header;
|
||||
};
|
|
@ -5,7 +5,7 @@ export interface ConfirmProps {
|
|||
title: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
content: string | React.ReactNode;
|
||||
onOk: () => Promise<void> | (() => void);
|
||||
onOk: (() => Promise<void>) | (() => void);
|
||||
onCancel?: () => Promise<void> | void;
|
||||
width?: number;
|
||||
cancelButtonProps?: {
|
||||
|
|
|
@ -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<typeof InputSelectModal> = {
|
||||
title: 'UI/Modal/InputSelectModal',
|
||||
component: InputSelectModal,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputSelectModal>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => {
|
||||
const [selectedItems, { replace }] = useSet<string>();
|
||||
|
||||
const onSelectChange = (items: string[]) => {
|
||||
replace(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputSelectModal
|
||||
{...args}
|
||||
open={true}
|
||||
selectedRowKeys={Array.from(selectedItems)}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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() }
|
||||
}))
|
||||
}
|
||||
};
|
|
@ -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<BorderedModalProps, 'children'> {
|
||||
inputPlaceholder?: string;
|
||||
columns: NonNullable<BusterListProps['columns']>;
|
||||
rows: NonNullable<BusterListProps['rows']>;
|
||||
emptyState: BusterListProps['emptyState'];
|
||||
onSelectChange: NonNullable<BusterListProps['onSelectChange']>;
|
||||
selectedRowKeys: NonNullable<BusterListProps['selectedRowKeys']>;
|
||||
showHeader?: NonNullable<BusterListProps['showHeader']>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<BorderedModal
|
||||
header={
|
||||
<>
|
||||
<InputSelecteHeader
|
||||
searchText={searchText}
|
||||
handleSearchChange={handleSearchChange}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
/>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Input Modal</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
</>
|
||||
}
|
||||
{...props}>
|
||||
<div
|
||||
className="max-h-90"
|
||||
style={{
|
||||
height: (filteredItems.length || 1) * 48 + (showHeader ? 32 : 0) //32 is the height of the header
|
||||
}}>
|
||||
<BusterList
|
||||
columns={columns}
|
||||
rows={filteredItems}
|
||||
onSelectChange={onSelectChange}
|
||||
emptyState={useMemo(
|
||||
() => emptyState || <Text variant={'secondary'}>No items found</Text>,
|
||||
[emptyState]
|
||||
)}
|
||||
showHeader={showHeader}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
useRowClickSelectChange={true}
|
||||
hideLastRowBorder
|
||||
/>
|
||||
</div>
|
||||
</BorderedModal>
|
||||
);
|
||||
}
|
||||
);
|
||||
InputSelectModal.displayName = 'InputScrollableModal';
|
||||
|
||||
const InputSelecteHeader: React.FC<{
|
||||
inputPlaceholder: string;
|
||||
searchText: string;
|
||||
handleSearchChange: (searchText: string) => void;
|
||||
}> = ({ inputPlaceholder, searchText, handleSearchChange }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
variant={'ghost'}
|
||||
type="text"
|
||||
size={'tall'}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -33,8 +33,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||
const DialogContent = React.memo(
|
||||
React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showClose?: boolean;
|
||||
}
|
||||
>(({ className, children, showClose = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
|
@ -47,13 +49,7 @@ const DialogContent = React.memo(
|
|||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
asChild
|
||||
className={cn(
|
||||
'absolute top-6 right-6 opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none'
|
||||
)}>
|
||||
<Button prefix={<Xmark />} variant="ghost" />
|
||||
</DialogPrimitive.Close>
|
||||
{showClose && <DialogCloseButton />}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
|
@ -66,7 +62,7 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('space-x-2 border-t px-6 py-2.5', className)} {...props} />
|
||||
<div className={cn('space-x-2 border-t py-2.5 pr-6 pl-5', className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
|
@ -94,6 +90,23 @@ const DialogDescription = React.forwardRef<
|
|||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
const DialogCloseButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Close
|
||||
ref={ref}
|
||||
asChild
|
||||
className={cn(
|
||||
'absolute top-6 right-6 opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none',
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<Button prefix={<Xmark />} variant="ghost" />
|
||||
</DialogPrimitive.Close>
|
||||
));
|
||||
DialogCloseButton.displayName = 'DialogCloseButton';
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
|
@ -104,5 +117,6 @@ export {
|
|||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
DialogDescription,
|
||||
DialogCloseButton
|
||||
};
|
||||
|
|
|
@ -221,7 +221,7 @@ function SegmentedTriggerComponent<T extends string = string>(props: SegmentedTr
|
|||
const LinkDiv = link ? Link : 'div';
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip || ''} sideOffset={10} delayDuration={0.15}>
|
||||
<Tooltip title={tooltip || ''} sideOffset={10} delayDuration={150}>
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
value={value}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
//here
|
||||
export * from './NewChatProvider';
|
||||
|
|
|
@ -140,7 +140,7 @@ const ChartButton: React.FC<{
|
|||
}> = React.memo(
|
||||
({ id, icon: Icon, tooltipText, onSelectChartType, isSelected, disabled, colors }) => {
|
||||
return (
|
||||
<AppTooltip title={tooltipText} delayDuration={0.65}>
|
||||
<AppTooltip title={tooltipText} delayDuration={650}>
|
||||
<div
|
||||
key={id}
|
||||
onClick={() => !disabled && onSelectChartType(id)}
|
||||
|
|
|
@ -1,66 +1,51 @@
|
|||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useMemoizedFn } from './useMemoizedFn';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
function useSet<K>(initialValue?: Iterable<K>) {
|
||||
const getInitValue = () => new Set(initialValue);
|
||||
const [set, setSet] = useState<Set<K>>(getInitValue);
|
||||
|
||||
/**
|
||||
* A hook that provides a stateful Set with methods to modify it.
|
||||
* @template T The type of elements in the Set
|
||||
* @returns A tuple containing the Set and methods to modify it
|
||||
*/
|
||||
export function useSet<T>(initialValues: T[] = []): [
|
||||
Set<T>,
|
||||
{
|
||||
add: (value: T) => void;
|
||||
remove: (value: T) => void;
|
||||
toggle: (value: T) => void;
|
||||
clear: () => void;
|
||||
has: (value: T) => boolean;
|
||||
}
|
||||
] {
|
||||
const [set, setSet] = useState<Set<T>>(() => new Set(initialValues));
|
||||
|
||||
const add = useCallback((value: T) => {
|
||||
const add = (key: K) => {
|
||||
if (set.has(key)) {
|
||||
return;
|
||||
}
|
||||
setSet((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
newSet.add(value);
|
||||
return newSet;
|
||||
const temp = new Set(prevSet);
|
||||
temp.add(key);
|
||||
return temp;
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const remove = useCallback((value: T) => {
|
||||
const remove = (key: K) => {
|
||||
if (!set.has(key)) {
|
||||
return;
|
||||
}
|
||||
setSet((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
newSet.delete(value);
|
||||
return newSet;
|
||||
const temp = new Set(prevSet);
|
||||
temp.delete(key);
|
||||
return temp;
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const toggle = useCallback((value: T) => {
|
||||
setSet((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
if (newSet.has(value)) {
|
||||
newSet.delete(value);
|
||||
} else {
|
||||
newSet.add(value);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
const reset = () => setSet(getInitValue());
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSet(new Set());
|
||||
}, []);
|
||||
const has = (key: K) => set.has(key);
|
||||
|
||||
const has = useCallback((value: T) => set.has(value), [set]);
|
||||
const size = () => set.size;
|
||||
|
||||
const replace = (newSet: K[]) => setSet(new Set(newSet));
|
||||
|
||||
return [
|
||||
set,
|
||||
{
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
clear,
|
||||
has
|
||||
add: useMemoizedFn(add),
|
||||
replace: useMemoizedFn(replace),
|
||||
remove: useMemoizedFn(remove),
|
||||
reset: useMemoizedFn(reset),
|
||||
has: useMemoizedFn(has),
|
||||
size: useMemoizedFn(size)
|
||||
}
|
||||
];
|
||||
] as const;
|
||||
}
|
||||
|
||||
export { useSet };
|
||||
|
|
|
@ -17,9 +17,12 @@ const createMockDashboardRow = (startIndex: number, metrics: string[], columnSiz
|
|||
items: metrics.map((metricId) => ({ id: metricId }))
|
||||
});
|
||||
|
||||
export const generateMockDashboard = (numMetrics: number): DashboardMockResponse => {
|
||||
export const generateMockDashboard = (
|
||||
numMetrics: number,
|
||||
dashboardId: string = '123'
|
||||
): DashboardMockResponse => {
|
||||
// Generate the specified number of metrics
|
||||
const metrics = Array.from({ length: numMetrics }, (_, i) => createMockMetric(`number${i + 1}`));
|
||||
const metrics = Array.from({ length: numMetrics }, (_, i) => createMockMetric(`${i + 1}`));
|
||||
const metricIds = metrics.map((metric) => metric.id);
|
||||
|
||||
// Create rows based on number of metrics
|
||||
|
@ -71,7 +74,7 @@ export const generateMockDashboard = (numMetrics: number): DashboardMockResponse
|
|||
}
|
||||
|
||||
const dashboard: BusterDashboard = {
|
||||
id: '123',
|
||||
id: dashboardId,
|
||||
name: 'Mock Dashboard',
|
||||
file: `title: Mock Dashboard
|
||||
description: A sample dashboard configuration
|
||||
|
|
|
@ -200,9 +200,9 @@ export const mockMetric30 = createMockMetric('number30');
|
|||
|
||||
export const createMockListMetric = (id: string): BusterMetricListItem => ({
|
||||
id,
|
||||
title: faker.lorem.words({ min: 2, max: 6 }),
|
||||
title: faker.commerce.productName(),
|
||||
last_edited: faker.date.recent().toISOString(),
|
||||
dataset_name: faker.lorem.words({ min: 2, max: 6 }),
|
||||
dataset_name: faker.commerce.productName(),
|
||||
dataset_uuid: faker.string.uuid(),
|
||||
created_by_id: faker.string.uuid(),
|
||||
created_by_name: faker.person.fullName(),
|
||||
|
|
Loading…
Reference in New Issue