Skip to content

Commit 1c1a4ed

Browse files
committed
feat: add drag and drop column component
1 parent cf8553e commit 1c1a4ed

File tree

4 files changed

+301
-0
lines changed

4 files changed

+301
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import '@testing-library/jest-dom';
4+
import Column from '../Column';
5+
6+
const mockColumns = [
7+
{ key: 'name', title: 'Name', isShownByDefault: true },
8+
{ key: 'status', title: 'Status', isShownByDefault: true },
9+
{ key: 'version', title: 'Version', isShownByDefault: false },
10+
];
11+
12+
describe('Column', () => {
13+
it('renders with initial columns', () => {
14+
render(<Column columns={mockColumns} />);
15+
expect(screen.getByLabelText('Name')).toBeChecked();
16+
expect(screen.getByLabelText('Status')).toBeChecked();
17+
expect(screen.getByLabelText('Version')).not.toBeChecked();
18+
});
19+
20+
it('renders title and description', () => {
21+
render(<Column columns={mockColumns} title="Test Title" description="Test Description" />);
22+
expect(screen.getByText('Test Title')).toBeInTheDocument();
23+
expect(screen.getByText('Test Description')).toBeInTheDocument();
24+
});
25+
26+
it('renders a cancel button', () => {
27+
const onCancel = jest.fn();
28+
render(<Column columns={mockColumns} onCancel={onCancel} />);
29+
const cancelButton = screen.getByText('Cancel');
30+
expect(cancelButton).toBeInTheDocument();
31+
userEvent.click(cancelButton);
32+
expect(onCancel).toHaveBeenCalled();
33+
});
34+
35+
it('toggles a column', async () => {
36+
const onSelect = jest.fn();
37+
render(<Column columns={mockColumns} onSelect={onSelect} />);
38+
const nameCheckbox = screen.getByLabelText('Name');
39+
await userEvent.click(nameCheckbox);
40+
expect(nameCheckbox).not.toBeChecked();
41+
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: 'name', isShown: false }));
42+
});
43+
44+
it('selects all columns', async () => {
45+
render(<Column columns={mockColumns} />);
46+
const menuToggle = screen.getByLabelText('Select all').closest('button');
47+
if (menuToggle) {
48+
await userEvent.click(menuToggle);
49+
}
50+
const selectAllButton = screen.getByText('Select all');
51+
await userEvent.click(selectAllButton);
52+
expect(screen.getByLabelText('Name')).toBeChecked();
53+
expect(screen.getByLabelText('Status')).toBeChecked();
54+
expect(screen.getByLabelText('Version')).toBeChecked();
55+
});
56+
57+
it('selects no columns', async () => {
58+
render(<Column columns={mockColumns} />);
59+
const menuToggle = screen.getByLabelText('Select all').closest('button');
60+
if (menuToggle) {
61+
await userEvent.click(menuToggle);
62+
}
63+
const selectNoneButton = screen.getByText('Select none');
64+
await userEvent.click(selectNoneButton);
65+
expect(screen.getByLabelText('Name')).not.toBeChecked();
66+
expect(screen.getByLabelText('Status')).not.toBeChecked();
67+
expect(screen.getByLabelText('Version')).not.toBeChecked();
68+
});
69+
70+
it('saves changes', async () => {
71+
const onSave = jest.fn();
72+
render(<Column columns={mockColumns} onSave={onSave} />);
73+
const saveButton = screen.getByText('Save');
74+
await userEvent.click(saveButton);
75+
expect(onSave).toHaveBeenCalledWith(expect.any(Array));
76+
});
77+
78+
it('reorders columns with drag and drop', () => {
79+
const onOrderChange = jest.fn();
80+
const { container } = render(<Column columns={mockColumns} onOrderChange={onOrderChange} />);
81+
const firstItem = screen.getByText('Name').closest('li');
82+
const secondItem = screen.getByText('Status').closest('li');
83+
84+
if (firstItem && secondItem) {
85+
fireEvent.dragStart(firstItem);
86+
fireEvent.dragEnter(secondItem);
87+
fireEvent.dragOver(secondItem);
88+
fireEvent.drop(secondItem);
89+
fireEvent.dragEnd(firstItem);
90+
91+
const listItems = container.querySelectorAll('li');
92+
expect(listItems[0].textContent).toContain('Status');
93+
expect(listItems[1].textContent).toContain('Name');
94+
expect(onOrderChange).toHaveBeenCalledWith([
95+
expect.objectContaining({ key: 'status' }),
96+
expect.objectContaining({ key: 'name' }),
97+
expect.objectContaining({ key: 'version' }),
98+
]);
99+
}
100+
});
101+
});
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type { FunctionComponent } from 'react';
2+
import { useState, useEffect } from 'react';
3+
import {
4+
DataListItem,
5+
DataList,
6+
DataListItemRow,
7+
DataListCheck,
8+
DataListCell,
9+
DataListItemCells,
10+
DataListControl,
11+
DataListDragButton,
12+
Button,
13+
ButtonVariant,
14+
Title,
15+
Checkbox,
16+
Dropdown,
17+
DropdownItem,
18+
MenuToggle
19+
} from '@patternfly/react-core';
20+
import {
21+
DragDrop,
22+
Droppable,
23+
Draggable
24+
} from '@patternfly/react-core/deprecated';
25+
26+
export interface ColumnColumn {
27+
/** Internal identifier of a column by which table displayed columns are filtered. */
28+
key: string;
29+
/** The actual display name of the column possibly with a tooltip or icon. */
30+
title: React.ReactNode;
31+
/** If user changes checkboxes, the component will send back column array with this property altered. */
32+
isShown?: boolean;
33+
/** Set to false if the column should be hidden initially */
34+
isShownByDefault: boolean;
35+
/** The checkbox will be disabled, this is applicable to columns which should not be toggleable by user */
36+
isUntoggleable?: boolean;
37+
}
38+
39+
export interface ColumnProps {
40+
/** Current column state */
41+
columns: ColumnColumn[];
42+
/* Column description text */
43+
description?: string;
44+
/* Column title text */
45+
title?: string;
46+
/** Custom OUIA ID */
47+
ouiaId?: string | number;
48+
/** Callback when a column is selected or deselected */
49+
onSelect?: (column: ColumnColumn) => void;
50+
/** Callback when the column order changes */
51+
onOrderChange?: (columns: ColumnColumn[]) => void;
52+
/** Callback to save the column state */
53+
onSave?: (columns: ColumnColumn[]) => void;
54+
/** Callback to close the modal */
55+
onCancel?: () => void;
56+
}
57+
58+
const Column: FunctionComponent<ColumnProps> = (
59+
{ columns,
60+
description,
61+
title,
62+
ouiaId = 'Column',
63+
onSelect,
64+
onOrderChange,
65+
onSave,
66+
onCancel }: ColumnProps) => {
67+
68+
const [ isDropdownOpen, setIsDropdownOpen ] = useState(false);
69+
const [ currentColumns, setCurrentColumns ] = useState(
70+
() => columns.map(column => ({ ...column, isShown: column.isShown ?? column.isShownByDefault, id: column.key }))
71+
);
72+
73+
useEffect(() => {
74+
setCurrentColumns(columns.map(column => ({ ...column, isShown: column.isShown ?? column.isShownByDefault, id: column.key })));
75+
}, [ columns ]);
76+
77+
const handleChange = index => {
78+
const newColumns = [ ...currentColumns ];
79+
const changedColumn = { ...newColumns[index] };
80+
81+
changedColumn.isShown = !changedColumn.isShown;
82+
newColumns[index] = changedColumn;
83+
84+
setCurrentColumns(newColumns);
85+
onSelect?.(changedColumn);
86+
};
87+
88+
const onDrag = (source, dest) => {
89+
if (dest) {
90+
const newColumns = [ ...currentColumns ];
91+
const [ removed ] = newColumns.splice(source.index, 1);
92+
newColumns.splice(dest.index, 0, removed);
93+
setCurrentColumns(newColumns);
94+
onOrderChange?.(newColumns);
95+
return true;
96+
}
97+
return false;
98+
};
99+
100+
const handleSave = () => {
101+
onSave?.(currentColumns);
102+
onCancel?.();
103+
}
104+
105+
const onSelectAll = (select = true) => {
106+
const newColumns = currentColumns.map(c => ({ ...c, isShown: c.isUntoggleable ? c.isShown : select }));
107+
setCurrentColumns(newColumns);
108+
onOrderChange?.(newColumns);
109+
}
110+
111+
const isAllSelected = () => currentColumns.every(c => c.isShown || c.isUntoggleable);
112+
const isSomeSelected = () => currentColumns.some(c => c.isShown);
113+
114+
const dropdownItems = [
115+
<DropdownItem key="select-all" onClick={() => onSelectAll(true)}>Select all</DropdownItem>,
116+
<DropdownItem key="deselect-all" onClick={() => onSelectAll(false)}>Select none</DropdownItem>
117+
];
118+
119+
const content = (
120+
<>
121+
<Title headingLevel="h3">{title}</Title>
122+
{description && <div style={{ paddingBottom: '1rem' }}><p>{description}</p></div>}
123+
<div style={{ paddingBottom: '1rem' }}>
124+
<Dropdown
125+
onSelect={() => setIsDropdownOpen(false)}
126+
toggle={(toggleRef) => (
127+
<MenuToggle
128+
ref={toggleRef}
129+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
130+
isExpanded={isDropdownOpen}
131+
>
132+
<Checkbox
133+
aria-label="Select all"
134+
isChecked={isAllSelected() ? true : isSomeSelected() ? null : false}
135+
id={`${ouiaId}-select-all-checkbox`}
136+
/>
137+
</MenuToggle>
138+
)}
139+
isOpen={isDropdownOpen}
140+
>
141+
{dropdownItems}
142+
</Dropdown>
143+
</div>
144+
<DragDrop onDrop={onDrag}>
145+
<Droppable droppableId="draggable-datalist">
146+
<DataList aria-label="Selected columns" isCompact data-ouia-component-id={`${ouiaId}-column-list`}>
147+
{currentColumns.map((column, index) =>
148+
<Draggable key={column.key} id={column.key}>
149+
<DataListItem key={column.key}>
150+
<DataListItemRow>
151+
<DataListControl>
152+
<DataListDragButton
153+
aria-label="Reorder"
154+
aria-labelledby={`${ouiaId}-column-${index}-label`}
155+
/>
156+
</DataListControl>
157+
<DataListCheck
158+
isChecked={column.isShown}
159+
onChange={() => handleChange(index)}
160+
isDisabled={column.isUntoggleable}
161+
aria-labelledby={`${ouiaId}-column-${index}-label`}
162+
ouiaId={`${ouiaId}-column-${index}-checkbox`}
163+
id={`${ouiaId}-column-${index}-checkbox`}
164+
/>
165+
<DataListItemCells
166+
dataListCells={[
167+
<DataListCell key={column.key} data-ouia-component-id={`${ouiaId}-column-${index}-label`}>
168+
<label htmlFor={`${ouiaId}-column-${index}-checkbox`} id={`${ouiaId}-column-${index}-label`}>
169+
{column.title}
170+
</label>
171+
</DataListCell>
172+
]}
173+
/>
174+
</DataListItemRow>
175+
</DataListItem>
176+
</Draggable>
177+
)}
178+
</DataList>
179+
</Droppable>
180+
</DragDrop>
181+
<div style={{ display: 'flex', justifyContent: 'normal', paddingTop: '1rem' }}>
182+
<Button key="save" variant={ButtonVariant.primary} onClick={handleSave} ouiaId={`${ouiaId}-save-button`}>
183+
Save
184+
</Button>
185+
<Button key="cancel" variant={ButtonVariant.link} onClick={onCancel} ouiaId={`${ouiaId}-cancel-button`}>
186+
Cancel
187+
</Button>
188+
</div>
189+
</>
190+
);
191+
192+
return content;
193+
}
194+
195+
export default Column;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './Column';
2+
export * from './Column';

packages/module/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export * from './ErrorBoundary';
7272
export { default as ColumnManagementModal } from './ColumnManagementModal';
7373
export * from './ColumnManagementModal';
7474

75+
export { default as Column } from './Column';
76+
export * from './Column';
77+
7578
export { default as CloseButton } from './CloseButton';
7679
export * from './CloseButton';
7780

0 commit comments

Comments
 (0)