diff --git a/web/src/components/ui/list/BusterList/BusterList.stories.tsx b/web/src/components/ui/list/BusterList/BusterList.stories.tsx index 8f95f0b8d..5c1242b15 100644 --- a/web/src/components/ui/list/BusterList/BusterList.stories.tsx +++ b/web/src/components/ui/list/BusterList/BusterList.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { BusterList } from './index'; import { BusterListRow } from './interfaces'; -import React from 'react'; +import React, { useMemo } from 'react'; import { faker } from '@faker-js/faker'; import { ContextMenuProps } from '../../context/ContextMenu'; @@ -40,23 +40,13 @@ const sampleColumns = [ { dataIndex: 'name', title: 'Name', - width: 200 + width: 100 }, { dataIndex: 'age', title: 'Age', width: 100 }, - { - dataIndex: 'address', - title: 'Address', - width: 200 - }, - { - dataIndex: 'email', - title: 'Email', - width: 100 - }, { dataIndex: 'actions', title: 'Actions', @@ -83,7 +73,7 @@ const generateSampleRows = (count: number): BusterListRow[] => { if (i === 3) { rows.push({ - id: 'section1', + id: 'section' + i, data: null, rowSection: { title: faker.company.name(), @@ -96,7 +86,7 @@ const generateSampleRows = (count: number): BusterListRow[] => { // Add a section row in the middle const sectionIndex = Math.floor(count / 2); rows.splice(sectionIndex, 0, { - id: 'section1', + id: 'section' + sectionIndex, data: null, rowSection: { title: faker.company.name(), @@ -142,19 +132,31 @@ export const Default: Story = { }; export const WithSelection: Story = { - args: { - columns: sampleColumns, - rows: sampleRows, - selectedRowKeys: [sampleRows[0].id, sampleRows[2].id], - showHeader: true, - showSelectAll: true, - onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`) - }, - render: (args) => ( -
- -
- ) + render: () => { + const sampleRows = useMemo(() => { + return generateSampleRows(50); + }, []); + + const [selectedKeys, setSelectedKeys] = React.useState([]); + + return ( +
+
+

+ Selected rows: {selectedKeys.join(', ') || 'None'} +

+
+ +
+ ); + } }; export const WithContextMenu: Story = { @@ -173,19 +175,28 @@ export const WithContextMenu: Story = { }; export const WithRowClickSelection: Story = { - args: { - columns: sampleColumns, - rows: sampleRows, - useRowClickSelectChange: true, - showHeader: true, - showSelectAll: true, - onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`) - }, - render: (args) => ( -
- -
- ) + render: () => { + const [selectedKeys, setSelectedKeys] = React.useState([]); + + return ( +
+
+

+ Selected rows: {selectedKeys.join(', ') || 'None'} +

+
+ +
+ ); + } }; export const WithoutHeader: Story = { @@ -216,38 +227,59 @@ export const EmptyState: Story = { }; export const BorderVariant: Story = { - args: { - columns: sampleColumns, - rows: generateSampleRows(30), - hideLastRowBorder: true, - showHeader: true, - showSelectAll: true, - onSelectChange: (selectedRowKeys) => alert(`Selected ${selectedRowKeys.join(', ')}`), - selectedRowKeys: generateSampleRows(30) - .filter((_, index) => index % 3 === 0) - .map((row) => row.id) - }, - render: (args) => ( -
-
-

Border Variant with Many Rows

-

- 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 -

-
- + render: () => { + const [selectedKeys, setSelectedKeys] = React.useState( + generateSampleRows(30) + .filter((_, index) => index % 3 === 0) + .map((row) => row.id) + ); + const rows = generateSampleRows(30); + + return ( +
+
+

Border Variant with Many Rows

+

+ 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 +

+
+

+ Selected rows: {selectedKeys.join(', ') || 'None'} +

+
+
+ +
+
+
+

Default Variant with Many Rows

+

+ This variant shows rows without container styling. +

+
+ +
-
-

Default Variant with Many Rows

-

This variant shows rows without container styling.

-
- -
-
-
- ) + ); + } }; // Story with many rows to demonstrate virtualization @@ -279,3 +311,28 @@ export const ManyRowsWithContextMenu: Story = {
) }; + +export const InteractiveSelection: Story = { + render: () => { + const [selectedKeys, setSelectedKeys] = React.useState([]); + + return ( +
+
+

+ Selected rows: {selectedKeys.join(', ') || 'None'} +

+
+ +
+ ); + } +}; diff --git a/web/src/components/ui/list/BusterList/BusterListRowComponent.tsx b/web/src/components/ui/list/BusterList/BusterListRowComponent.tsx index 36063e6c1..5bf29eed9 100644 --- a/web/src/components/ui/list/BusterList/BusterListRowComponent.tsx +++ b/web/src/components/ui/list/BusterList/BusterListRowComponent.tsx @@ -14,7 +14,7 @@ export const BusterListRowComponent = React.memo( row: BusterListRow; columns: BusterListColumn[]; checked: boolean; - onSelectChange?: (v: boolean, id: string) => void; + onSelectChange?: (v: boolean, id: string, e: React.MouseEvent) => void; onContextMenuClick?: (e: React.MouseEvent, id: string) => void; style?: React.CSSProperties; hideLastRowBorder: NonNullable; @@ -44,13 +44,13 @@ export const BusterListRowComponent = React.memo( onContextMenuClick?.(e, row.id); }); - const onChange = useMemoizedFn((newChecked: boolean) => { - onSelectChange?.(newChecked, row.id); + const onChange = useMemoizedFn((newChecked: boolean, e: React.MouseEvent) => { + onSelectChange?.(newChecked, row.id, e); }); - const onContainerClick = useMemoizedFn(() => { + const onContainerClick = useMemoizedFn((e: React.MouseEvent) => { if (useRowClickSelectChange) { - onChange(!checked); + onChange(!checked, e); } row.onClick?.(); }); @@ -110,7 +110,7 @@ const BusterListCellComponent: React.FC<{ isFirstCell?: boolean; isLastCell?: boolean; 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; }> = React.memo(({ data, width, row, render, isFirstCell, isLastCell, onSelectChange }) => { const memoizedStyle = useMemo(() => { diff --git a/web/src/components/ui/list/BusterList/BusterListRowComponentSelector.tsx b/web/src/components/ui/list/BusterList/BusterListRowComponentSelector.tsx index ec502df12..b61018bdb 100644 --- a/web/src/components/ui/list/BusterList/BusterListRowComponentSelector.tsx +++ b/web/src/components/ui/list/BusterList/BusterListRowComponentSelector.tsx @@ -9,7 +9,7 @@ export const BusterListRowComponentSelector = React.forwardRef< row: BusterListRow; columns: BusterListColumn[]; id: string; - onSelectChange?: (v: boolean, id: string) => void; + onSelectChange?: (v: boolean, id: string, e: React.MouseEvent) => void; onSelectSectionChange?: (v: boolean, id: string) => void; onContextMenuClick?: (e: React.MouseEvent, id: string) => void; selectedRowKeys?: string[]; diff --git a/web/src/components/ui/list/BusterList/BusterListVirtua.tsx b/web/src/components/ui/list/BusterList/BusterListVirtua.tsx index bcbb85aae..8c72c796d 100644 --- a/web/src/components/ui/list/BusterList/BusterListVirtua.tsx +++ b/web/src/components/ui/list/BusterList/BusterListVirtua.tsx @@ -1,7 +1,7 @@ 'use client'; import { VList } from 'virtua'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { BusterListProps } from './interfaces'; import { useMemoizedFn } from '@/hooks'; import { getAllIdsInSection } from './helpers'; @@ -26,6 +26,7 @@ export const BusterListVirtua = React.memo( }: BusterListProps) => { const showEmptyState = (!rows || rows.length === 0) && !!emptyState; const lastChildIndex = rows.length - 1; + const lastSelectedIdRef = useRef(null); const globalCheckStatus = useMemo(() => { if (!selectedRowKeys) return 'unchecked'; @@ -49,15 +50,45 @@ export const BusterListVirtua = React.memo( } }); - const onSelectChangePreflight = useMemoizedFn((v: boolean, id: string) => { - if (!onSelectChange || !selectedRowKeys) return; - if (v === false) { - onSelectChange(selectedRowKeys?.filter((d) => d !== id)); - } else { - onSelectChange(selectedRowKeys?.concat(id) || []); - } + const getItemsBetween = useMemoizedFn((startId: string, endId: string) => { + const startIndex = rows.findIndex((row) => row.id === startId); + const endIndex = rows.findIndex((row) => row.id === endId); + + if (startIndex === -1 || endIndex === -1) return []; + + 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 row = rows[index]; return row.rowSection ? HEIGHT_OF_SECTION_ROW : HEIGHT_OF_ROW; @@ -83,10 +114,11 @@ export const BusterListVirtua = React.memo( hideLastRowBorder ]); - const WrapperNode = !!contextMenu ? ContextMenu : React.Fragment; - const wrapperNodeProps: ContextMenuProps = !!contextMenu - ? contextMenu - : ({} as ContextMenuProps); + const [WrapperNode, wrapperNodeProps] = useMemo(() => { + const node = !!contextMenu ? ContextMenu : React.Fragment; + const props: ContextMenuProps = !!contextMenu ? contextMenu : ({} as ContextMenuProps); + return [node, props]; + }, [contextMenu]); return ( diff --git a/web/src/components/ui/list/BusterList/CheckboxColumn.tsx b/web/src/components/ui/list/BusterList/CheckboxColumn.tsx index e90209830..55c607ac5 100644 --- a/web/src/components/ui/list/BusterList/CheckboxColumn.tsx +++ b/web/src/components/ui/list/BusterList/CheckboxColumn.tsx @@ -6,7 +6,7 @@ import { cn } from '@/lib/classMerge'; export const CheckboxColumn: React.FC<{ checkStatus: 'checked' | 'unchecked' | 'indeterminate' | undefined; - onChange: (v: boolean) => void; + onChange: (v: boolean, e: React.MouseEvent) => void; className?: string; }> = React.memo(({ checkStatus, onChange, className = '' }) => { const showBox = checkStatus === 'checked'; //|| checkStatus === 'indeterminate'; @@ -16,10 +16,6 @@ export const CheckboxColumn: React.FC<{ e.preventDefault(); }); - const onChangePreflight = useMemoizedFn((e: boolean) => { - onChange(e); - }); - return (
); diff --git a/web/src/components/ui/list/BusterList/MemoizedCheckbox.tsx b/web/src/components/ui/list/BusterList/MemoizedCheckbox.tsx index c7f787309..7e94f3f41 100644 --- a/web/src/components/ui/list/BusterList/MemoizedCheckbox.tsx +++ b/web/src/components/ui/list/BusterList/MemoizedCheckbox.tsx @@ -11,14 +11,20 @@ export const MemoizedCheckbox = React.memo( }: { checked: boolean; indeterminate: boolean; - onChange: (v: boolean) => void; + onChange: (v: boolean, e: React.MouseEvent) => void; }) => { - const handleChange = useMemoizedFn((checkedState: CheckedState) => { - onChange?.(checkedState === true); - }); + const handleChange = useMemoizedFn( + (checkedState: CheckedState, e: React.MouseEvent) => { + onChange?.(checkedState === true, e); + } + ); return ( - + handleChange(!checked, e)} + /> ); } ); diff --git a/web/src/controllers/DashboardController/DashboardController.tsx b/web/src/controllers/DashboardController/DashboardController.tsx index 5ffa990c0..db5bdc801 100644 --- a/web/src/controllers/DashboardController/DashboardController.tsx +++ b/web/src/controllers/DashboardController/DashboardController.tsx @@ -8,13 +8,11 @@ import { AddTypeModal } from '@/components/features/modal/AddTypeModal'; import { useMemoizedFn } from '@/hooks'; import { useGetDashboard } from '@/api/buster_rest/dashboards'; -export const DashboardController: React.FC<{ dashboardId: string; readOnly?: boolean }> = ({ - dashboardId, - readOnly = false -}) => { +export const DashboardController: React.FC<{ dashboardId: string }> = ({ dashboardId }) => { const { data: dashboardResponse, isFetched: isFetchedDashboard } = useGetDashboard(dashboardId); const selectedFileView = useChatLayoutContextSelector((x) => x.selectedFileView) || 'dashboard'; const [openAddTypeModal, setOpenAddTypeModal] = useState(false); + const onCloseModal = useMemoizedFn(() => { setOpenAddTypeModal(false); }); diff --git a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/DashboardContainerHeaderButtons/DashboardContainerHeaderButtons.tsx b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/DashboardContainerHeaderButtons/DashboardContainerHeaderButtons.tsx index a5343c365..e893b9ff1 100644 --- a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/DashboardContainerHeaderButtons/DashboardContainerHeaderButtons.tsx +++ b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/DashboardContainerHeaderButtons/DashboardContainerHeaderButtons.tsx @@ -41,7 +41,7 @@ SaveToCollectionButton.displayName = 'SaveToCollectionButton'; const AddContentToDashboardButton = React.memo(() => { return ( - +