import * as React from 'react' import type { StoryFn, Meta } from '@storybook/react-webpack5' import { ListGrid } from './index' import type { ListGridProps } from './types' import type { GridActionsMenuFullProps, GridConfirmPayload, GridRowDropInfo, GridRowMeta, } from '../../types' import { GridCellButton, GridCellDefault } from '../../components' import { Cancel, Edit } from '@planview/pv-icons' import { useGridRow } from '../../context/grid-context' import { GridEditorInput, GridEditorSwitch } from '../../editors' import { ListItem, ListItemDivider, Menu } from '@planview/pv-uikit' import GridMeta from '../grid/index.stories' export default { title: 'pv-grid/Components/ListGrid', component: ListGrid, parameters: { badges: ['intl'], }, argTypes: { rows: GridMeta.argTypes.rows, columns: GridMeta.argTypes.columns, loading: GridMeta.argTypes.loading, emptyContent: GridMeta.argTypes.emptyContent, expandedRows: GridMeta.argTypes.expandedRows, onExpandedRowsChange: GridMeta.argTypes.onExpandedRowsChange, rowDrag: GridMeta.argTypes.rowDrag, onCellChange: GridMeta.argTypes.onCellChange, ref: GridMeta.argTypes.ref, actionsMenu: { control: { type: 'radio' }, options: ['no actions menu', 'actions menu', 'actions menu async'], mapping: { 'no actions menu': undefined, 'actions menu': () => ( <> ), 'actions menu async': { Menu({ row, menuProps }: GridActionsMenuFullProps) { const [loading, setLoading] = React.useState(true) React.useEffect(() => { const t = setTimeout(() => setLoading(false), 500) return () => clearTimeout(t) }, []) return ( <> ) }, }, }, table: { category: 'Context Menu', }, }, }, } satisfies Meta type KpiCategory = { id: string label: string isIntegrated?: boolean inUse?: boolean } export const Default: StoryFn> = ({ columns: _columns, rows: _rows, rowDrag: _rowDrag, ...rest }) => { const [sortedRows, setSortedRows] = React.useState([ { id: '1', label: 'Financial' }, { id: '2', label: 'Customer', inUse: true }, { id: '3', label: 'Internal Process' }, { id: '4', label: 'Learning and Growth' }, { id: '5', label: 'System', isIntegrated: true }, ]) return ( resultIds.map( (id) => rows.find((row) => row.id === id)! ) ) } }, }} {...rest} /> ) } export const ActionsMenu: StoryFn> = ({ columns: _columns, rows: _rows, ...rest }) => { const [sortedRows, setSortedRows] = React.useState([ { id: '1', label: 'Financial' }, { id: '2', label: 'Customer', inUse: true }, { id: '3', label: 'Internal Process' }, { id: '4', label: 'Learning and Growth' }, { id: '5', label: 'System', isIntegrated: true }, ]) return ( resultIds.map( (id) => rows.find((row) => row.id === id)! ) ) } }, }} actionsMenu={() => ( <> )} /> ) } type KpiTreeCategory = { id: string label: string isIntegrated?: boolean inUse?: boolean parentId: string | null index: number } function sortByIndex(a: KpiTreeCategory, b: KpiTreeCategory) { return a.index - b.index } export const Tree: StoryFn> = () => { const [sourceRows, setSourceRows] = React.useState([ { id: '1', label: 'Financial', parentId: null, index: 0 }, { id: '2', label: 'Customer', inUse: true, parentId: '1', index: 0 }, { id: '3', label: 'Internal Process', parentId: null, index: 1 }, { id: '4', label: 'Learning and Growth', parentId: '1', index: 1 }, { id: '5', label: 'System', isIntegrated: true, parentId: null, index: 2, }, { id: '6', label: 'System Subsection', isIntegrated: true, parentId: '5', index: 0, }, ]) const rows = React.useMemo(() => { // These three items are needed by the grid const ids: string[] = [] const data = new Map() const meta = new Map>() const sortedRows = sourceRows.concat() sortedRows.sort(sortByIndex) sortedRows.forEach((row) => { // Populate the raw data map data.set(row.id, row) if (row.parentId === null) { // If there is no parentId, this row is at the root level // so add it to the root `ids` array. ids.push(row.id) } else { // This is a child, so fetch its parent's meta data if it exists // and add it to the `children` array const children = meta.get(row.parentId)?.children ?? [] children.push(row.id) meta.set(row.parentId, { type: 'tree', children }) } }) return { ids, data, meta } }, [sourceRows]) const onDrop = React.useCallback( (info: GridRowDropInfo) => { const draggedIds = new Set(info.draggedRowIds) const impactedParentIds = new Set( info.draggedRows.map((d) => d.row.parentId) ) // This is where you could kick off an API request to move the // impacted items on the server. The code below would be needed // regardless to provide an optimistic change to the user // immediately. In the event of a server error, you could revert // the activities to their original state setSourceRows((prev) => { // Make deep copy of activities, to avoid mutating in-memory const newRows = structuredClone(prev) // Find rows in their original order // You can't go off of raw index order since absolute position // in the list is influenced by parent nesting. const draggedRows = info.draggedRowIds.map( (id) => newRows.find((d) => d.id === id)! ) // Steps 1 & 2 – Remove from parents and heal siblings. // parentId may be `null` indicating root impactedParentIds.forEach((parentId) => { const oldSiblings = newRows.filter( (r) => r.parentId === parentId && !draggedIds.has(r.id) ) oldSiblings.sort(sortByIndex) oldSiblings.forEach((sibling, index) => { sibling.index = index }) }) // Step 3 - Insert dragged items in the correct location // and adjust sibling indexes const newSiblings = newRows.filter( (r) => r.parentId === info.targetParentId && !draggedIds.has(r.id) ) newSiblings.sort(sortByIndex) newSiblings.splice( // If rows were dropped on a parent and not in a specific // location, the targetIndex will be null. In this case // they are inserted at the end info.targetIndex == null ? newSiblings.length : info.targetIndex, 0, ...draggedRows ) // Step 4 - Adjust indexes of siblings and dropped rows // This also sets the parentId for the dropped rows as the other // siblings already have this value set newSiblings.forEach((sibling, index) => { sibling.index = index sibling.parentId = info.targetParentId }) return newRows }) }, [] ) return ( } tooltip="Delete category" onClick={() => { // void deleteRow(rowId) }} /> ) } return ( ) }, }, }, { id: 'actions', label: 'Actions', width: 36, cell: { Renderer({ tabIndex, rowId }) { return ( } tooltip="Delete category" onClick={() => { // void deleteRow(rowId) }} /> ) }, }, }, ]} rows={rows} rowDrag={{ enableLeafConversion: true, onDrop, }} /> ) } export const ReorderableAndEditable: StoryFn< ListGridProps > = () => { const [sortedRows, setSortedRows] = React.useState([ { id: '1', label: 'Financial' }, { id: '2', label: 'Customer', inUse: true }, { id: '3', label: 'Internal Process' }, { id: '4', label: 'Learning and Growth' }, { id: '5', label: 'System', isIntegrated: true }, ]) return ( (rowId) if (row.inUse || row.isIntegrated) { return ( ) } return ( } tooltip="Delete category" onClick={() => { // void deleteRow(rowId) }} /> ) }, }, }, ]} rows={sortedRows} rowDrag={{ previewColumnId: 'label', onDrop({ resultIds }) { if (resultIds) { setSortedRows((rows) => resultIds.map( (id) => rows.find((row) => row.id === id)! ) ) } }, }} onCellChange={(payload: GridConfirmPayload) => { setSortedRows((rows) => rows.map((row) => { if (row.id === payload.rowId) { return { ...row, [payload.columnId]: payload.nextValue, } } return row }) ) }} /> ) } export const ActiveAndEditable: StoryFn> = () => { const [sortedRows, setSortedRows] = React.useState([ { id: '1', label: 'Row 1', enabled: false }, { id: '2', label: 'Row 2', enabled: true }, { id: '3', label: 'Row 3', enabled: false }, ]) return ( } /> ) }, }, }, ]} rows={sortedRows} rowDrag={{ previewColumnId: 'label', onDrop({ resultIds }) { if (resultIds) { setSortedRows((rows) => resultIds.map( (id) => rows.find((row) => row.id === id)! ) ) } }, }} onCellChange={(payload: GridConfirmPayload) => { setSortedRows((rows) => rows.map((row) => { if (row.id === payload.rowId) { return { ...row, [payload.columnId]: payload.nextValue, } } return row }) ) }} /> ) }