Skip to content

Commit 7932c2d

Browse files
authored
chore(storage-browser): use DataTable for locations view table (#5675)
* chore(storage-browser): use DataTable for locations view table * use useMemo around 'getLocationsData' * clean up icon logic * add tests for LocationsViewTableControl * remove unnecessary comment/notes * wip change selection to string * update import * create string array for columns? * use key-value pair for column display name * fix typo
1 parent e205bfe commit 7932c2d

File tree

4 files changed

+258
-4
lines changed

4 files changed

+258
-4
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from 'react';
2+
3+
import { capitalize } from '@aws-amplify/ui';
4+
5+
import {
6+
DataTable,
7+
TABLE_DATA_BUTTON_CLASS,
8+
TABLE_HEADER_BUTTON_CLASS_NAME,
9+
TABLE_HEADER_CLASS_NAME,
10+
} from '../../../components/DataTable';
11+
import { useControl } from '../../../context/controls';
12+
import { useLocationsData } from '../../../context/actions';
13+
import { LocationAccess } from '../../../context/types';
14+
import { compareStrings } from '../../../context/controls/Table';
15+
import { ButtonElement, IconElement } from '../../../context/elements';
16+
17+
export type SortDirection = 'ascending' | 'descending' | 'none';
18+
19+
export type SortState = {
20+
selection: string;
21+
direction: SortDirection;
22+
};
23+
24+
const getCompareFn = (selection: string) => {
25+
switch (selection) {
26+
case 'scope':
27+
case 'type':
28+
case 'permission':
29+
return compareStrings;
30+
}
31+
};
32+
33+
const getColumnItem = ({
34+
entry,
35+
selection,
36+
direction,
37+
onTableHeaderClick,
38+
}: {
39+
entry: [string, string];
40+
selection: string;
41+
direction: SortDirection;
42+
onTableHeaderClick: (location: string) => void;
43+
}) => {
44+
const [key, value] = entry;
45+
46+
return {
47+
children: (
48+
<ButtonElement variant="sort" className={TABLE_HEADER_BUTTON_CLASS_NAME}>
49+
{capitalize(value)}
50+
<IconElement
51+
variant={
52+
selection === key && direction !== 'none'
53+
? `sort-${direction}`
54+
: 'sort-indeterminate'
55+
}
56+
/>
57+
</ButtonElement>
58+
),
59+
key: `th_${key}`,
60+
className: `${TABLE_HEADER_CLASS_NAME} ${TABLE_HEADER_CLASS_NAME}--${key}`,
61+
onClick: () => onTableHeaderClick(key),
62+
'aria-sort': selection === key ? direction : 'none',
63+
};
64+
};
65+
66+
const displayColumns: Record<string, string>[] = [
67+
{ scope: 'name' },
68+
{ type: 'type' },
69+
{ permission: 'permission' },
70+
];
71+
72+
const getLocationsData = ({
73+
data,
74+
onLocationClick,
75+
onTableHeaderClick,
76+
sortState,
77+
}: {
78+
data: LocationAccess[];
79+
onLocationClick: (location: LocationAccess) => void;
80+
onTableHeaderClick: (location: string) => void;
81+
sortState: SortState;
82+
}) => {
83+
const { selection, direction } = sortState;
84+
85+
const columns = displayColumns.flatMap((column) =>
86+
Object.entries(column).map((entry) =>
87+
getColumnItem({ entry, selection, direction, onTableHeaderClick })
88+
)
89+
);
90+
91+
const compareFn = getCompareFn(selection);
92+
93+
if (compareFn) {
94+
const castSelection = selection as keyof LocationAccess;
95+
96+
if (direction === 'ascending') {
97+
data.sort((a, b) => compareFn(a[castSelection], b[castSelection]));
98+
} else {
99+
data.sort((a, b) => compareFn(b[castSelection], a[castSelection]));
100+
}
101+
}
102+
103+
const rows = data.map((location, index) => [
104+
{
105+
key: `td-name-${index}`,
106+
children: (
107+
<ButtonElement
108+
className={TABLE_DATA_BUTTON_CLASS}
109+
onClick={() => onLocationClick(location)}
110+
variant="table-data"
111+
>
112+
{location.scope}
113+
</ButtonElement>
114+
),
115+
},
116+
{ key: `td-type-${index}`, children: location.type },
117+
{ key: `td-permission-${index}`, children: location.permission },
118+
]);
119+
120+
return { columns, rows };
121+
};
122+
123+
export function DataTableControl(): React.JSX.Element {
124+
const [{ data }] = useLocationsData();
125+
126+
const [, handleUpdateState] = useControl({ type: 'NAVIGATE' });
127+
128+
const [sortState, setSortState] = React.useState<SortState>({
129+
selection: 'scope',
130+
direction: 'ascending',
131+
});
132+
133+
const locationsData = React.useMemo(
134+
() =>
135+
getLocationsData({
136+
data: data.result,
137+
sortState,
138+
onLocationClick: (location) => {
139+
handleUpdateState({
140+
type: 'ACCESS_LOCATION',
141+
location,
142+
});
143+
},
144+
onTableHeaderClick: (location: string) => {
145+
setSortState((prevState) => ({
146+
selection: location,
147+
direction:
148+
prevState.direction === 'ascending' ? 'descending' : 'ascending',
149+
}));
150+
},
151+
}),
152+
[data.result, handleUpdateState, sortState]
153+
);
154+
155+
return <DataTable data={locationsData} />;
156+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import React from 'react';
2+
3+
import { render, screen, fireEvent } from '@testing-library/react';
4+
5+
import { DataTableControl } from '../DataTable';
6+
import * as UseControlModule from '../../../../context/controls';
7+
import * as UseLocationsDataModule from '../../../../context/actions';
8+
import createProvider from '../../../../createProvider';
9+
import { LocationAccess } from '../../../../context/types';
10+
11+
const useControlModuleSpy = jest.spyOn(UseControlModule, 'useControl');
12+
const useLocationsDataSpy = jest.spyOn(
13+
UseLocationsDataModule,
14+
'useLocationsData'
15+
);
16+
17+
const mockData: LocationAccess<UseLocationsDataModule.Permission>[] = [
18+
{ scope: 'Location A', type: 'BUCKET', permission: 'READ' },
19+
{ scope: 'Location B', type: 'PREFIX', permission: 'WRITE' },
20+
];
21+
22+
const listLocations = jest.fn(() =>
23+
Promise.resolve({ locations: [], nextToken: undefined })
24+
);
25+
26+
const config = {
27+
getLocationCredentials: jest.fn(),
28+
listLocations,
29+
region: 'region',
30+
registerAuthListener: jest.fn(),
31+
};
32+
33+
const Provider = createProvider({ actions: {}, config });
34+
35+
describe('LocationsViewTableControl', () => {
36+
beforeEach(() => {
37+
useControlModuleSpy.mockReturnValue([{}, jest.fn()]);
38+
useLocationsDataSpy.mockReturnValue([
39+
{
40+
data: { result: mockData, nextToken: undefined },
41+
hasError: false,
42+
isLoading: false,
43+
message: undefined,
44+
},
45+
jest.fn(),
46+
]);
47+
});
48+
49+
it('renders the table with data', () => {
50+
const { getByText } = render(
51+
<Provider>
52+
<DataTableControl />
53+
</Provider>
54+
);
55+
56+
expect(getByText('Name')).toBeInTheDocument();
57+
expect(getByText('Type')).toBeInTheDocument();
58+
expect(getByText('Permission')).toBeInTheDocument();
59+
expect(getByText('Location A')).toBeInTheDocument();
60+
expect(getByText('Location B')).toBeInTheDocument();
61+
});
62+
63+
it('renders the correct icon based on sort state', () => {
64+
const { getByText } = render(
65+
<Provider>
66+
<DataTableControl />
67+
</Provider>
68+
);
69+
70+
const nameTh = screen.getByRole('columnheader', { name: 'Name' });
71+
72+
expect(nameTh).toHaveAttribute('aria-sort', 'ascending');
73+
74+
fireEvent.click(getByText('Name'));
75+
76+
expect(nameTh).toHaveAttribute('aria-sort', 'descending');
77+
});
78+
79+
it('triggers location click handler when a row is clicked', () => {
80+
const mockHandleUpdateState = jest.fn();
81+
useControlModuleSpy.mockReturnValue([{}, mockHandleUpdateState]);
82+
83+
render(
84+
<Provider>
85+
<DataTableControl />
86+
</Provider>
87+
);
88+
89+
const firstRowButton = screen.getByRole('button', { name: 'Location A' });
90+
fireEvent.click(firstRowButton);
91+
92+
expect(mockHandleUpdateState).toHaveBeenCalledWith({
93+
type: 'ACCESS_LOCATION',
94+
location: mockData[0],
95+
});
96+
});
97+
});

packages/react-storage/src/components/StorageBrowser/Views/LocationsView/LocationsView.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@ import { StorageBrowserElements } from '../../context/elements';
55
import { CLASS_BASE } from '../constants';
66
import { Controls } from '../Controls';
77
import { CommonControl, ViewComponent } from '../types';
8-
import { LocationsViewTable } from '../Controls';
98
import { useLocationsData } from '../../context/actions';
9+
import { DataTableControl } from './Controls/DataTable';
1010

1111
const {
1212
Loading: LoadingElement,
1313
Message,
1414
Paginate,
1515
Refresh,
1616
Search,
17-
Table,
1817
Title,
1918
} = Controls;
2019

@@ -61,6 +60,7 @@ export const LocationsMessage = (): React.JSX.Element | null => {
6160
) : null;
6261
};
6362

63+
// @ts-expect-error TODO: add Controls assignment
6464
const LocationsViewControls: LocationsViewControls = () => {
6565
return (
6666
<>
@@ -69,7 +69,7 @@ const LocationsViewControls: LocationsViewControls = () => {
6969
<Paginate />
7070
<LocationsMessage />
7171
<Loading />
72-
<LocationsViewTable />
72+
<DataTableControl />
7373
</>
7474
);
7575
};
@@ -78,7 +78,6 @@ LocationsViewControls.Message = Message;
7878
LocationsViewControls.Paginate = Paginate;
7979
LocationsViewControls.Refresh = Refresh;
8080
LocationsViewControls.Search = Search;
81-
LocationsViewControls.Table = Table;
8281
LocationsViewControls.Title = Title;
8382

8483
export const LocationsView: LocationsView = () => {

packages/react-storage/src/components/StorageBrowser/components/DataTable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { CLASS_BASE } from '../Views/constants';
1313

1414
export const TABLE_CLASS_NAME = `${CLASS_BASE}__table`;
1515
export const TABLE_HEADER_CLASS_NAME = `${TABLE_CLASS_NAME}__header`;
16+
export const TABLE_HEADER_BUTTON_CLASS_NAME = `${TABLE_CLASS_NAME}__header__button`;
1617
export const TABLE_ROW_CLASS_NAME = `${TABLE_CLASS_NAME}__row`;
1718
export const TABLE_DATA_CLASS_NAME = `${TABLE_CLASS_NAME}__data`;
19+
export const TABLE_DATA_BUTTON_CLASS = `${TABLE_CLASS_NAME}__data__button`;
1820

1921
export interface ColumnHeaderItemProps
2022
extends React.ComponentProps<typeof TableHeaderElement> {}

0 commit comments

Comments
 (0)