dashboard and select updates

This commit is contained in:
Nate Kelley 2025-03-20 16:41:00 -06:00
parent 869eecc80c
commit 5d632413ec
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
9 changed files with 195 additions and 106 deletions

View File

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { BusterList } from './index'; import { BusterList } from './index';
import { BusterListRow } from './interfaces'; import { BusterListRow } from './interfaces';
import React from 'react'; import React, { useMemo } from 'react';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { ContextMenuProps } from '../../context/ContextMenu'; import { ContextMenuProps } from '../../context/ContextMenu';
@ -40,23 +40,13 @@ const sampleColumns = [
{ {
dataIndex: 'name', dataIndex: 'name',
title: 'Name', title: 'Name',
width: 200 width: 100
}, },
{ {
dataIndex: 'age', dataIndex: 'age',
title: 'Age', title: 'Age',
width: 100 width: 100
}, },
{
dataIndex: 'address',
title: 'Address',
width: 200
},
{
dataIndex: 'email',
title: 'Email',
width: 100
},
{ {
dataIndex: 'actions', dataIndex: 'actions',
title: 'Actions', title: 'Actions',
@ -83,7 +73,7 @@ const generateSampleRows = (count: number): BusterListRow[] => {
if (i === 3) { if (i === 3) {
rows.push({ rows.push({
id: 'section1', id: 'section' + i,
data: null, data: null,
rowSection: { rowSection: {
title: faker.company.name(), title: faker.company.name(),
@ -96,7 +86,7 @@ const generateSampleRows = (count: number): BusterListRow[] => {
// Add a section row in the middle // Add a section row in the middle
const sectionIndex = Math.floor(count / 2); const sectionIndex = Math.floor(count / 2);
rows.splice(sectionIndex, 0, { rows.splice(sectionIndex, 0, {
id: 'section1', id: 'section' + sectionIndex,
data: null, data: null,
rowSection: { rowSection: {
title: faker.company.name(), title: faker.company.name(),
@ -142,19 +132,31 @@ export const Default: Story = {
}; };
export const WithSelection: Story = { export const WithSelection: Story = {
args: { render: () => {
columns: sampleColumns, const sampleRows = useMemo(() => {
rows: sampleRows, return generateSampleRows(50);
selectedRowKeys: [sampleRows[0].id, sampleRows[2].id], }, []);
showHeader: true,
showSelectAll: true, const [selectedKeys, setSelectedKeys] = React.useState<string[]>([]);
onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`)
}, return (
render: (args) => (
<div style={{ height: '400px', width: '800px' }}> <div style={{ height: '400px', width: '800px' }}>
<BusterList {...args} /> <div className="mb-4">
<p className="rounded border border-blue-200 bg-blue-300 p-1 text-sm text-blue-900">
Selected rows: {selectedKeys.join(', ') || 'None'}
</p>
</div> </div>
) <BusterList
columns={sampleColumns}
rows={sampleRows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
showHeader={true}
showSelectAll={true}
/>
</div>
);
}
}; };
export const WithContextMenu: Story = { export const WithContextMenu: Story = {
@ -173,19 +175,28 @@ export const WithContextMenu: Story = {
}; };
export const WithRowClickSelection: Story = { export const WithRowClickSelection: Story = {
args: { render: () => {
columns: sampleColumns, const [selectedKeys, setSelectedKeys] = React.useState<string[]>([]);
rows: sampleRows,
useRowClickSelectChange: true, return (
showHeader: true,
showSelectAll: true,
onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`)
},
render: (args) => (
<div style={{ height: '400px', width: '800px' }}> <div style={{ height: '400px', width: '800px' }}>
<BusterList {...args} /> <div className="mb-4">
<p className="text-sm text-gray-500">
Selected rows: {selectedKeys.join(', ') || 'None'}
</p>
</div> </div>
) <BusterList
columns={sampleColumns}
rows={sampleRows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
useRowClickSelectChange={true}
showHeader={true}
showSelectAll={true}
/>
</div>
);
}
}; };
export const WithoutHeader: Story = { export const WithoutHeader: Story = {
@ -216,18 +227,15 @@ export const EmptyState: Story = {
}; };
export const BorderVariant: Story = { export const BorderVariant: Story = {
args: { render: () => {
columns: sampleColumns, const [selectedKeys, setSelectedKeys] = React.useState<string[]>(
rows: generateSampleRows(30), generateSampleRows(30)
hideLastRowBorder: true,
showHeader: true,
showSelectAll: true,
onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`),
selectedRowKeys: generateSampleRows(30)
.filter((_, index) => index % 3 === 0) .filter((_, index) => index % 3 === 0)
.map((row) => row.id) .map((row) => row.id)
}, );
render: (args) => ( const rows = generateSampleRows(30);
return (
<div className="flex flex-col gap-4" style={{ height: '900px', width: '800px' }}> <div className="flex flex-col gap-4" style={{ height: '900px', width: '800px' }}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-lg font-medium">Border Variant with Many Rows</h3> <h3 className="text-lg font-medium">Border Variant with Many Rows</h3>
@ -235,19 +243,43 @@ export const BorderVariant: Story = {
This variant remove the border of the last row. This is useful when you want to put this This variant remove the border of the last row. This is useful when you want to put this
list inside of a container that already contains a border list inside of a container that already contains a border
</p> </p>
<div className="mb-4">
<p className="text-sm text-gray-500">
Selected rows: {selectedKeys.join(', ') || 'None'}
</p>
</div>
<div className="min-h-[300px]"> <div className="min-h-[300px]">
<BusterList {...args} /> <BusterList
columns={sampleColumns}
rows={rows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
hideLastRowBorder={true}
showHeader={true}
showSelectAll={true}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-lg font-medium">Default Variant with Many Rows</h3> <h3 className="text-lg font-medium">Default Variant with Many Rows</h3>
<p className="text-sm text-gray-500">This variant shows rows without container styling.</p> <p className="text-sm text-gray-500">
This variant shows rows without container styling.
</p>
<div className="min-h-[300px]"> <div className="min-h-[300px]">
<BusterList {...args} hideLastRowBorder={false} /> <BusterList
columns={sampleColumns}
rows={rows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
hideLastRowBorder={false}
showHeader={true}
showSelectAll={true}
/>
</div> </div>
</div> </div>
</div> </div>
) );
}
}; };
// Story with many rows to demonstrate virtualization // Story with many rows to demonstrate virtualization
@ -279,3 +311,28 @@ export const ManyRowsWithContextMenu: Story = {
</div> </div>
) )
}; };
export const InteractiveSelection: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = React.useState<string[]>([]);
return (
<div style={{ height: '400px', width: '800px' }}>
<div className="mb-4">
<p className="text-sm text-gray-500">
Selected rows: {selectedKeys.join(', ') || 'None'}
</p>
</div>
<BusterList
columns={sampleColumns}
rows={sampleRows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
showHeader={true}
showSelectAll={true}
useRowClickSelectChange={true}
/>
</div>
);
}
};

View File

@ -14,7 +14,7 @@ export const BusterListRowComponent = React.memo(
row: BusterListRow; row: BusterListRow;
columns: BusterListColumn[]; columns: BusterListColumn[];
checked: boolean; checked: boolean;
onSelectChange?: (v: boolean, id: string) => void; onSelectChange?: (v: boolean, id: string, e: React.MouseEvent) => void;
onContextMenuClick?: (e: React.MouseEvent<HTMLDivElement>, id: string) => void; onContextMenuClick?: (e: React.MouseEvent<HTMLDivElement>, id: string) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
hideLastRowBorder: NonNullable<BusterListProps['hideLastRowBorder']>; hideLastRowBorder: NonNullable<BusterListProps['hideLastRowBorder']>;
@ -44,13 +44,13 @@ export const BusterListRowComponent = React.memo(
onContextMenuClick?.(e, row.id); onContextMenuClick?.(e, row.id);
}); });
const onChange = useMemoizedFn((newChecked: boolean) => { const onChange = useMemoizedFn((newChecked: boolean, e: React.MouseEvent) => {
onSelectChange?.(newChecked, row.id); onSelectChange?.(newChecked, row.id, e);
}); });
const onContainerClick = useMemoizedFn(() => { const onContainerClick = useMemoizedFn((e: React.MouseEvent) => {
if (useRowClickSelectChange) { if (useRowClickSelectChange) {
onChange(!checked); onChange(!checked, e);
} }
row.onClick?.(); row.onClick?.();
}); });
@ -110,7 +110,7 @@ const BusterListCellComponent: React.FC<{
isFirstCell?: boolean; isFirstCell?: boolean;
isLastCell?: boolean; isLastCell?: boolean;
width?: number | undefined; width?: number | undefined;
onSelectChange?: (v: boolean, id: string) => void; onSelectChange?: (v: boolean, id: string, e: React.MouseEvent) => void;
render?: (data: string | number | React.ReactNode, row: BusterListRowItem) => React.ReactNode; render?: (data: string | number | React.ReactNode, row: BusterListRowItem) => React.ReactNode;
}> = React.memo(({ data, width, row, render, isFirstCell, isLastCell, onSelectChange }) => { }> = React.memo(({ data, width, row, render, isFirstCell, isLastCell, onSelectChange }) => {
const memoizedStyle = useMemo(() => { const memoizedStyle = useMemo(() => {

View File

@ -9,7 +9,7 @@ export const BusterListRowComponentSelector = React.forwardRef<
row: BusterListRow; row: BusterListRow;
columns: BusterListColumn[]; columns: BusterListColumn[];
id: string; id: string;
onSelectChange?: (v: boolean, id: string) => void; onSelectChange?: (v: boolean, id: string, e: React.MouseEvent) => void;
onSelectSectionChange?: (v: boolean, id: string) => void; onSelectSectionChange?: (v: boolean, id: string) => void;
onContextMenuClick?: (e: React.MouseEvent<HTMLDivElement>, id: string) => void; onContextMenuClick?: (e: React.MouseEvent<HTMLDivElement>, id: string) => void;
selectedRowKeys?: string[]; selectedRowKeys?: string[];

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { VList } from 'virtua'; import { VList } from 'virtua';
import React, { useMemo } from 'react'; import React, { useMemo, useRef } from 'react';
import { BusterListProps } from './interfaces'; import { BusterListProps } from './interfaces';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { getAllIdsInSection } from './helpers'; import { getAllIdsInSection } from './helpers';
@ -26,6 +26,7 @@ export const BusterListVirtua = React.memo(
}: BusterListProps) => { }: BusterListProps) => {
const showEmptyState = (!rows || rows.length === 0) && !!emptyState; const showEmptyState = (!rows || rows.length === 0) && !!emptyState;
const lastChildIndex = rows.length - 1; const lastChildIndex = rows.length - 1;
const lastSelectedIdRef = useRef<string | null>(null);
const globalCheckStatus = useMemo(() => { const globalCheckStatus = useMemo(() => {
if (!selectedRowKeys) return 'unchecked'; if (!selectedRowKeys) return 'unchecked';
@ -49,15 +50,45 @@ export const BusterListVirtua = React.memo(
} }
}); });
const onSelectChangePreflight = useMemoizedFn((v: boolean, id: string) => { const getItemsBetween = useMemoizedFn((startId: string, endId: string) => {
if (!onSelectChange || !selectedRowKeys) return; const startIndex = rows.findIndex((row) => row.id === startId);
if (v === false) { const endIndex = rows.findIndex((row) => row.id === endId);
onSelectChange(selectedRowKeys?.filter((d) => d !== id));
} else { if (startIndex === -1 || endIndex === -1) return [];
onSelectChange(selectedRowKeys?.concat(id) || []);
} const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
return rows
.slice(start, end + 1)
.filter((row) => !row.rowSection && !row.hidden)
.map((row) => row.id);
}); });
const onSelectChangePreflight = useMemoizedFn(
(v: boolean, id: string, event?: React.MouseEvent) => {
if (!onSelectChange || !selectedRowKeys) return;
if (event?.shiftKey && lastSelectedIdRef.current) {
const itemsBetween = getItemsBetween(lastSelectedIdRef.current, id);
if (v) {
const newSelectedKeys = Array.from(new Set([...selectedRowKeys, ...itemsBetween]));
onSelectChange(newSelectedKeys);
} else {
onSelectChange(selectedRowKeys.filter((key) => !itemsBetween.includes(key)));
}
} else {
if (v === false) {
onSelectChange(selectedRowKeys.filter((d) => d !== id));
} else {
onSelectChange(selectedRowKeys.concat(id));
}
}
lastSelectedIdRef.current = id;
}
);
const itemSize = useMemoizedFn((index: number) => { const itemSize = useMemoizedFn((index: number) => {
const row = rows[index]; const row = rows[index];
return row.rowSection ? HEIGHT_OF_SECTION_ROW : HEIGHT_OF_ROW; return row.rowSection ? HEIGHT_OF_SECTION_ROW : HEIGHT_OF_ROW;
@ -83,10 +114,11 @@ export const BusterListVirtua = React.memo(
hideLastRowBorder hideLastRowBorder
]); ]);
const WrapperNode = !!contextMenu ? ContextMenu : React.Fragment; const [WrapperNode, wrapperNodeProps] = useMemo(() => {
const wrapperNodeProps: ContextMenuProps = !!contextMenu const node = !!contextMenu ? ContextMenu : React.Fragment;
? contextMenu const props: ContextMenuProps = !!contextMenu ? contextMenu : ({} as ContextMenuProps);
: ({} as ContextMenuProps); return [node, props];
}, [contextMenu]);
return ( return (
<WrapperNode {...wrapperNodeProps}> <WrapperNode {...wrapperNodeProps}>

View File

@ -6,7 +6,7 @@ import { cn } from '@/lib/classMerge';
export const CheckboxColumn: React.FC<{ export const CheckboxColumn: React.FC<{
checkStatus: 'checked' | 'unchecked' | 'indeterminate' | undefined; checkStatus: 'checked' | 'unchecked' | 'indeterminate' | undefined;
onChange: (v: boolean) => void; onChange: (v: boolean, e: React.MouseEvent) => void;
className?: string; className?: string;
}> = React.memo(({ checkStatus, onChange, className = '' }) => { }> = React.memo(({ checkStatus, onChange, className = '' }) => {
const showBox = checkStatus === 'checked'; //|| checkStatus === 'indeterminate'; const showBox = checkStatus === 'checked'; //|| checkStatus === 'indeterminate';
@ -16,10 +16,6 @@ export const CheckboxColumn: React.FC<{
e.preventDefault(); e.preventDefault();
}); });
const onChangePreflight = useMemoizedFn((e: boolean) => {
onChange(e);
});
return ( return (
<div <div
onClick={onClickStopPropagation} onClick={onClickStopPropagation}
@ -35,7 +31,7 @@ export const CheckboxColumn: React.FC<{
<MemoizedCheckbox <MemoizedCheckbox
checked={checkStatus === 'checked'} checked={checkStatus === 'checked'}
indeterminate={checkStatus === 'indeterminate'} indeterminate={checkStatus === 'indeterminate'}
onChange={onChangePreflight} onChange={onChange}
/> />
</div> </div>
); );

View File

@ -11,14 +11,20 @@ export const MemoizedCheckbox = React.memo(
}: { }: {
checked: boolean; checked: boolean;
indeterminate: boolean; indeterminate: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean, e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}) => { }) => {
const handleChange = useMemoizedFn((checkedState: CheckedState) => { const handleChange = useMemoizedFn(
onChange?.(checkedState === true); (checkedState: CheckedState, e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
}); onChange?.(checkedState === true, e);
}
);
return ( return (
<Checkbox checked={checked} indeterminate={indeterminate} onCheckedChange={handleChange} /> <Checkbox
checked={checked}
indeterminate={indeterminate}
onClick={(e) => handleChange(!checked, e)}
/>
); );
} }
); );

View File

@ -8,13 +8,11 @@ import { AddTypeModal } from '@/components/features/modal/AddTypeModal';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { useGetDashboard } from '@/api/buster_rest/dashboards'; import { useGetDashboard } from '@/api/buster_rest/dashboards';
export const DashboardController: React.FC<{ dashboardId: string; readOnly?: boolean }> = ({ export const DashboardController: React.FC<{ dashboardId: string }> = ({ dashboardId }) => {
dashboardId,
readOnly = false
}) => {
const { data: dashboardResponse, isFetched: isFetchedDashboard } = useGetDashboard(dashboardId); const { data: dashboardResponse, isFetched: isFetchedDashboard } = useGetDashboard(dashboardId);
const selectedFileView = useChatLayoutContextSelector((x) => x.selectedFileView) || 'dashboard'; const selectedFileView = useChatLayoutContextSelector((x) => x.selectedFileView) || 'dashboard';
const [openAddTypeModal, setOpenAddTypeModal] = useState(false); const [openAddTypeModal, setOpenAddTypeModal] = useState(false);
const onCloseModal = useMemoizedFn(() => { const onCloseModal = useMemoizedFn(() => {
setOpenAddTypeModal(false); setOpenAddTypeModal(false);
}); });

View File

@ -41,7 +41,7 @@ SaveToCollectionButton.displayName = 'SaveToCollectionButton';
const AddContentToDashboardButton = React.memo(() => { const AddContentToDashboardButton = React.memo(() => {
return ( return (
<AppTooltip title="Add to dashboard"> <AppTooltip title="Add content">
<Button variant="ghost" prefix={<Plus />} /> <Button variant="ghost" prefix={<Plus />} />
</AppTooltip> </AppTooltip>
); );

View File

@ -164,7 +164,7 @@ const useDashboardSelectMenu = ({ metricId }: { metricId: string }) => {
const dashboardDropdownItem: DropdownItem = useMemo( const dashboardDropdownItem: DropdownItem = useMemo(
() => ({ () => ({
label: 'Add to dashboard', label: 'Add content',
value: 'add-to-dashboard', value: 'add-to-dashboard',
icon: <ASSET_ICONS.dashboardAdd />, icon: <ASSET_ICONS.dashboardAdd />,
items: [<React.Fragment key="dashboard-sub-menu">{dashboardSubMenu}</React.Fragment>] items: [<React.Fragment key="dashboard-sub-menu">{dashboardSubMenu}</React.Fragment>]