Skip to content

Commit f13792f

Browse files
authored
feat(content-explorer): migrate list view to blueprint (#3930)
* feat(content-explorer): migrate list view to blueprint * feat(content-explorer): add i18n messages for list-view * fix: add unit tests * feat: update e2e tests * fix: update visual tests * fix: update query in visual tests * fix: resolve interaction flakiness * fix: flakiness in visual tests * fix: flakiness in visual tests * fix: flakiness in visual tests * fix: resolve interaction flakiness * fix: resolve interaction flakiness * fix: resolve interaction flakiness * fix: resolve interaction flakiness * fix: disable flaky tests
1 parent 23acee4 commit f13792f

30 files changed

+950
-1267
lines changed

i18n/en-US.properties

+14
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,20 @@ be.itemCreated = Created
524524
be.itemGrid.gridView = Grid view
525525
# Label for item last accessed date.
526526
be.itemInteracted = Last Accessed
527+
# Label for the column header in the list view for the available user actions on the item
528+
be.itemList.actionsColumn = ACTIONS
529+
# Label for the column header in the list view for the date the item was modified
530+
be.itemList.dateColumn = UPDATED
531+
# Label for the column header in the list view for the combined details of the item
532+
be.itemList.detailsColumn = DETAILS
533+
# Concatenated text of the modified date and item size of the file or folder
534+
be.itemList.itemSubtitle = {date} • {size}
535+
# Label for the list of files and folders displayed in a list view
536+
be.itemList.listView = List view
537+
# Label for the column header in the list view for the name of the item
538+
be.itemList.nameColumn = NAME
539+
# Label for the column header in the list view for the size of the item
540+
be.itemList.sizeColumn = SIZE
527541
# Label for item modified date.
528542
be.itemModified = Modified
529543
# Label for item name attribute.

src/elements/common/item-grid/ItemGrid.tsx

+13-6
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,28 @@ import { GridList } from '@box/blueprint-web';
77
import { ItemDate, ItemOptions, ItemTypeIcon } from '../item';
88
import { isThumbnailAvailable } from '../utils';
99

10-
import { TYPE_FOLDER, TYPE_WEBLINK } from '../../../constants';
10+
import { TYPE_FOLDER, TYPE_WEBLINK, VIEW_MODE_GRID } from '../../../constants';
1111

1212
import messages from './messages';
1313

1414
import type { BoxItem, View } from '../../../common/types/core';
15-
import type { ItemEventHandlers, ItemEventPermissions } from '../item';
15+
import type { ItemAction, ItemEventHandlers, ItemEventPermissions } from '../item';
1616

1717
import './ItemGrid.scss';
1818

1919
export interface ItemGridProps extends ItemEventHandlers, ItemEventPermissions {
2020
gridColumnCount?: number;
2121
isTouch?: boolean;
22+
itemActions?: ItemAction[];
2223
items: BoxItem[];
2324
view: View;
2425
}
2526

2627
const ItemGrid = ({
27-
canPreview = false,
28+
canPreview,
2829
gridColumnCount = 1,
30+
isTouch,
2931
items,
30-
isTouch = false,
3132
onItemClick = noop,
3233
view,
3334
...rest
@@ -50,7 +51,13 @@ const ItemGrid = ({
5051
};
5152

5253
return (
53-
<GridList.Item key={id} onAction={handleAction} textValue={name}>
54+
<GridList.Item
55+
key={id}
56+
className="be-ItemGrid-item"
57+
id={id}
58+
onAction={handleAction}
59+
textValue={name}
60+
>
5461
<GridList.Thumbnail>
5562
{thumbnailUrl && isThumbnailAvailable(item) ? (
5663
<img alt={name} src={thumbnailUrl} />
@@ -64,7 +71,7 @@ const ItemGrid = ({
6471
<GridList.Subtitle>
6572
<ItemDate item={item} view={view} />
6673
</GridList.Subtitle>
67-
<ItemOptions canPreview={canPreview} isGridView item={item} {...rest} />
74+
<ItemOptions canPreview={canPreview} item={item} viewMode={VIEW_MODE_GRID} {...rest} />
6875
</GridList.Item>
6976
);
7077
})}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.be-ItemList-header {
2+
.be-is-small & {
3+
visibility: collapse;
4+
}
5+
}
6+
7+
.be-ItemList-nameCell {
8+
display: flex;
9+
gap: var(--space-3);
10+
}
11+
12+
.be-ItemList-itemIcon {
13+
flex-shrink: 0;
14+
}
15+
16+
.be-ItemList-itemDetails {
17+
display: flex;
18+
flex-direction: column;
19+
justify-content: center;
20+
overflow: hidden;
21+
}
22+
23+
.be-ItemList-itemTitle,
24+
.be-ItemList-itemSubtitle {
25+
overflow: hidden;
26+
text-overflow: ellipsis;
27+
}
+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import React from 'react';
2+
import { useIntl } from 'react-intl';
3+
import noop from 'lodash/noop';
4+
import type { SelectionMode } from 'react-aria-components';
5+
6+
import { Cell, Column, Row, Table, TableBody, TableHeader, Text } from '@box/blueprint-web';
7+
8+
import { ItemDate, ItemOptions, ItemTypeIcon } from '../item';
9+
10+
import getSize from '../../../utils/size';
11+
12+
import { SORT_ASC, SORT_DESC, TYPE_FOLDER, TYPE_WEBLINK, VIEW_MODE_LIST, VIEW_RECENTS } from '../../../constants';
13+
import { ITEM_ICON_SIZE, ITEM_LIST_COLUMNS, MEDIUM_ITEM_LIST_COLUMNS, SMALL_ITEM_LIST_COLUMNS } from './constants';
14+
15+
import messages from './messages';
16+
17+
import type { BoxItem, SortBy, SortDirection, View } from '../../../common/types/core';
18+
import type { ItemAction, ItemEventHandlers, ItemEventPermissions } from '../item';
19+
import type { ItemListColumn } from './types';
20+
21+
import './ItemList.scss';
22+
23+
export interface ItemListProps extends ItemEventHandlers, ItemEventPermissions {
24+
isMedium?: boolean;
25+
isSmall?: boolean;
26+
isTouch?: boolean;
27+
itemActions?: ItemAction[];
28+
items: BoxItem[];
29+
onSortChange?: (sortBy: SortBy, sortDirection: SortDirection) => void;
30+
selectionMode?: SelectionMode;
31+
sortBy?: SortBy;
32+
sortDirection?: SortDirection;
33+
view: View;
34+
}
35+
36+
const ItemList = ({
37+
canPreview,
38+
isMedium,
39+
isSmall,
40+
isTouch,
41+
items,
42+
onItemClick = noop,
43+
onSortChange = noop,
44+
selectionMode,
45+
sortBy,
46+
sortDirection,
47+
view,
48+
...rest
49+
}: ItemListProps) => {
50+
const { formatMessage } = useIntl();
51+
52+
let defaultColumns: ItemListColumn[] = ITEM_LIST_COLUMNS;
53+
54+
if (isSmall) {
55+
defaultColumns = SMALL_ITEM_LIST_COLUMNS;
56+
}
57+
58+
if (isMedium) {
59+
defaultColumns = MEDIUM_ITEM_LIST_COLUMNS;
60+
}
61+
62+
const listColumns = defaultColumns.map(({ messageId, ...column }) => {
63+
return { children: formatMessage(messages[messageId]), key: column.id, ...column };
64+
});
65+
66+
// TODO: Refactor ContentExplorer to use SortDirection system from Blueprint
67+
const handleSortChange = sortDescriptor => {
68+
const { column, direction } = sortDescriptor;
69+
70+
onSortChange(column, direction === 'ascending' ? SORT_ASC : SORT_DESC);
71+
};
72+
73+
return (
74+
<Table
75+
aria-label={formatMessage(messages.listView)}
76+
className="be-ItemList"
77+
sortDescriptor={{
78+
column: sortBy,
79+
direction: sortDirection === SORT_ASC ? 'ascending' : 'descending',
80+
}}
81+
onSortChange={handleSortChange}
82+
selectionMode={selectionMode}
83+
>
84+
<TableHeader className="be-ItemList-header" columns={listColumns}>
85+
{({ children, ...columnProps }) => <Column {...columnProps}>{children}</Column>}
86+
</TableHeader>
87+
<TableBody items={items.map(item => ({ key: item.id, ...item }))}>
88+
{item => {
89+
const { id, name, size, type } = item;
90+
91+
const handleAction = () => {
92+
if (type === TYPE_FOLDER || (!isTouch && (type === TYPE_WEBLINK || canPreview))) {
93+
onItemClick(item);
94+
}
95+
};
96+
97+
return (
98+
<Row id={id} className="be-ItemList-item" onAction={handleAction}>
99+
<Cell>
100+
<div className="be-ItemList-nameCell">
101+
<ItemTypeIcon
102+
className="be-ItemList-itemIcon"
103+
dimension={ITEM_ICON_SIZE}
104+
item={item}
105+
/>
106+
<div className="be-ItemList-itemDetails">
107+
<Text as="span" className="be-ItemList-itemTitle" variant="bodyDefaultSemibold">
108+
{name}
109+
</Text>
110+
{isSmall && view !== VIEW_RECENTS ? (
111+
<Text
112+
as="span"
113+
className="be-ItemList-itemSubtitle"
114+
color="textOnLightSecondary"
115+
>
116+
{formatMessage(messages.itemSubtitle, {
117+
date: <ItemDate isSmall item={item} view={view} />,
118+
size: getSize(size),
119+
})}
120+
</Text>
121+
) : null}
122+
</div>
123+
</div>
124+
</Cell>
125+
{isSmall ? null : (
126+
<Cell>
127+
<Text as="span" color="textOnLightSecondary">
128+
<ItemDate item={item} view={view} />
129+
</Text>
130+
</Cell>
131+
)}
132+
{isSmall || isMedium ? null : (
133+
<Cell>
134+
<Text as="span" color="textOnLightSecondary">
135+
{getSize(size)}
136+
</Text>
137+
</Cell>
138+
)}
139+
<ItemOptions canPreview={canPreview} item={item} viewMode={VIEW_MODE_LIST} {...rest} />
140+
</Row>
141+
);
142+
}}
143+
</TableBody>
144+
</Table>
145+
);
146+
};
147+
148+
export default ItemList;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React from 'react';
2+
import userEvent from '@testing-library/user-event';
3+
import { render, screen } from '../../../../test-utils/testing-library';
4+
import ItemList from '../ItemList';
5+
6+
describe('elements/common/item-list/ItemList', () => {
7+
const renderComponent = (props = {}) => {
8+
const defaultProps = {
9+
items: [
10+
{ type: 'folder', id: '001', name: 'Shared folder', modified_at: '2021-10-18T09:00:00-07:00' },
11+
{ type: 'folder', id: '002', name: 'Documents', modified_at: '2021-10-18T09:00:00-07:00' },
12+
{ type: 'file', id: '003', name: 'Presentation', modified_at: '2021-10-18T09:00:00-07:00' },
13+
],
14+
view: 'files',
15+
};
16+
render(<ItemList {...defaultProps} {...props} />);
17+
};
18+
19+
test('renders component correctly', () => {
20+
renderComponent();
21+
22+
expect(screen.getByRole('grid', { name: 'List view' })).toBeInTheDocument();
23+
expect(screen.getAllByRole('columnheader').length).toBe(4);
24+
expect(screen.getByRole('columnheader', { name: 'NAME' })).toBeInTheDocument();
25+
expect(screen.getByRole('columnheader', { name: 'UPDATED' })).toBeInTheDocument();
26+
expect(screen.getByRole('columnheader', { name: 'SIZE' })).toBeInTheDocument();
27+
expect(screen.getByRole('columnheader', { name: 'ACTIONS' })).toBeInTheDocument();
28+
expect(screen.getByRole('row', { name: /Shared folder$/ })).toBeInTheDocument();
29+
expect(screen.getByRole('row', { name: /Documents$/ })).toBeInTheDocument();
30+
expect(screen.getByRole('row', { name: /Presentation$/ })).toBeInTheDocument();
31+
});
32+
33+
test('renders component with correct columns for small screen devices', () => {
34+
renderComponent({ isSmall: true });
35+
36+
expect(screen.getAllByRole('columnheader').length).toBe(2);
37+
expect(screen.getByRole('columnheader', { name: 'DETAILS' })).toBeInTheDocument();
38+
expect(screen.getByRole('columnheader', { name: 'ACTIONS' })).toBeInTheDocument();
39+
});
40+
41+
test('renders component with correct columns for medium screen devices', () => {
42+
renderComponent({ isMedium: true });
43+
44+
expect(screen.getAllByRole('columnheader').length).toBe(3);
45+
expect(screen.getByRole('columnheader', { name: 'NAME' })).toBeInTheDocument();
46+
expect(screen.getByRole('columnheader', { name: 'UPDATED' })).toBeInTheDocument();
47+
expect(screen.getByRole('columnheader', { name: 'ACTIONS' })).toBeInTheDocument();
48+
});
49+
50+
test('renders component with correct columns for medium screen devices', () => {
51+
renderComponent({ isMedium: true });
52+
53+
expect(screen.getAllByRole('columnheader').length).toBe(3);
54+
expect(screen.getByRole('columnheader', { name: 'NAME' })).toBeInTheDocument();
55+
expect(screen.getByRole('columnheader', { name: 'UPDATED' })).toBeInTheDocument();
56+
expect(screen.getByRole('columnheader', { name: 'ACTIONS' })).toBeInTheDocument();
57+
});
58+
59+
test('renders component with item details when device screen is small', () => {
60+
const items = [
61+
{ type: 'file', id: '004', name: 'Box file', modified_at: '2021-10-18T09:00:00-07:00', size: 2048 },
62+
];
63+
64+
renderComponent({ isSmall: true, items });
65+
66+
expect(screen.getByText('Oct 18, 2021 • 2 KB')).toBeInTheDocument();
67+
});
68+
69+
test('does not render component with item details when `view` is `recents`', () => {
70+
const items = [
71+
{ type: 'file', id: '004', name: 'Box file', modified_at: '2021-10-18T09:00:00-07:00', size: 2048 },
72+
];
73+
74+
renderComponent({ isSmall: true, items, view: 'recents' });
75+
76+
expect(screen.queryByText('Oct 18, 2021 • 2 KB')).not.toBeInTheDocument();
77+
});
78+
79+
test.each(['folder', 'file', 'web_link'])(
80+
'calls `onItemClick` when a %s item is clicked with preview enabled',
81+
async type => {
82+
const items = [{ type, id: '004', name: 'Box item', modified_at: '2021-10-18T09:00:00-07:00' }];
83+
const onItemClick = jest.fn();
84+
85+
renderComponent({ canPreview: true, items, onItemClick });
86+
87+
expect(onItemClick).not.toHaveBeenCalled();
88+
89+
await userEvent.click(screen.getByRole('row', { name: /Box item$/ }));
90+
91+
expect(onItemClick).toHaveBeenCalledTimes(1);
92+
},
93+
);
94+
95+
test('does not call `onItemClick` when a file item is clicked with preview disabled', async () => {
96+
const items = [{ type: 'file', id: '004', name: 'Box file', modified_at: '2021-10-18T09:00:00-07:00' }];
97+
const onItemClick = jest.fn();
98+
99+
renderComponent({ canPreview: false, items, onItemClick });
100+
101+
await userEvent.click(screen.getByRole('row', { name: /Box file$/ }));
102+
103+
expect(onItemClick).not.toHaveBeenCalled();
104+
});
105+
106+
test.each(['file', 'web_link'])(
107+
'does not call `onItemClick` when a %s item is clicked on a touch device with preview enabled',
108+
async type => {
109+
const items = [{ type, id: '004', name: 'Box item', modified_at: '2021-10-18T09:00:00-07:00' }];
110+
const onItemClick = jest.fn();
111+
112+
renderComponent({ canPreview: true, isTouch: true, items, onItemClick });
113+
114+
await userEvent.click(screen.getByRole('row', { name: /Box item$/ }));
115+
116+
expect(onItemClick).not.toHaveBeenCalled();
117+
},
118+
);
119+
});

0 commit comments

Comments
 (0)