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' }}> <div className="mb-4">
<BusterList {...args} /> <p className="rounded border border-blue-200 bg-blue-300 p-1 text-sm text-blue-900">
</div> Selected rows: {selectedKeys.join(', ') || 'None'}
) </p>
</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, <div style={{ height: '400px', width: '800px' }}>
showSelectAll: true, <div className="mb-4">
onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`) <p className="text-sm text-gray-500">
}, Selected rows: {selectedKeys.join(', ') || 'None'}
render: (args) => ( </p>
<div style={{ height: '400px', width: '800px' }}> </div>
<BusterList {...args} /> <BusterList
</div> columns={sampleColumns}
) rows={sampleRows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
useRowClickSelectChange={true}
showHeader={true}
showSelectAll={true}
/>
</div>
);
}
}; };
export const WithoutHeader: Story = { export const WithoutHeader: Story = {
@ -216,38 +227,59 @@ 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, .filter((_, index) => index % 3 === 0)
showHeader: true, .map((row) => row.id)
showSelectAll: true, );
onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`), const rows = generateSampleRows(30);
selectedRowKeys: generateSampleRows(30)
.filter((_, index) => index % 3 === 0) return (
.map((row) => row.id) <div className="flex flex-col gap-4" style={{ height: '900px', width: '800px' }}>
}, <div className="flex flex-col gap-2">
render: (args) => ( <h3 className="text-lg font-medium">Border Variant with Many Rows</h3>
<div className="flex flex-col gap-4" style={{ height: '900px', width: '800px' }}> <p className="text-sm text-gray-500">
<div className="flex flex-col gap-2"> This variant remove the border of the last row. This is useful when you want to put this
<h3 className="text-lg font-medium">Border Variant with Many Rows</h3> list inside of a container that already contains a border
<p className="text-sm text-gray-500"> </p>
This variant remove the border of the last row. This is useful when you want to put this <div className="mb-4">
list inside of a container that already contains a border <p className="text-sm text-gray-500">
</p> Selected rows: {selectedKeys.join(', ') || 'None'}
<div className="min-h-[300px]"> </p>
<BusterList {...args} /> </div>
<div className="min-h-[300px]">
<BusterList
columns={sampleColumns}
rows={rows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
hideLastRowBorder={true}
showHeader={true}
showSelectAll={true}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<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>
<div className="min-h-[300px]">
<BusterList
columns={sampleColumns}
rows={rows}
selectedRowKeys={selectedKeys}
onSelectChange={setSelectedKeys}
hideLastRowBorder={false}
showHeader={true}
showSelectAll={true}
/>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> );
<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>
<div className="min-h-[300px]">
<BusterList {...args} hideLastRowBorder={false} />
</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>]