Skip to content

Commit 5d88f33

Browse files
support sortable columns in workflows list
Signed-off-by: Adhitya Mamallan <adhitya.mamallan@uber.com>
1 parent 678c947 commit 5d88f33

File tree

8 files changed

+213
-7
lines changed

8 files changed

+213
-7
lines changed

src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ErrorPanel from '@/components/error-panel/error-panel';
44
import PanelSection from '@/components/panel-section/panel-section';
55
import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator';
66
import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params';
7+
import { toggleSortOrder } from '@/utils/sort-by';
78
import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config';
89
import useListWorkflows from '@/views/shared/hooks/use-list-workflows';
910
import WorkflowsList from '@/views/shared/workflows-list/workflows-list';
@@ -21,7 +22,9 @@ export default function DomainWorkflowsArchivalList({
2122
timeRangeStart,
2223
timeRangeEnd,
2324
}: Props) {
24-
const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig);
25+
const [queryParams, setQueryParams] = usePageQueryParams(
26+
domainPageQueryParamsConfig
27+
);
2528
const { inputType } = useArchivalInputType();
2629

2730
const {
@@ -74,6 +77,24 @@ export default function DomainWorkflowsArchivalList({
7477
hasNextPage={hasNextPage}
7578
fetchNextPage={fetchNextPage}
7679
isFetchingNextPage={isFetchingNextPage}
80+
sortParams={
81+
queryParams.inputTypeArchival === 'search'
82+
? {
83+
onSort: (column: string) =>
84+
setQueryParams({
85+
sortColumnArchival: column,
86+
sortOrderArchival: toggleSortOrder({
87+
currentSortColumn: queryParams.sortColumnArchival,
88+
currentSortOrder: queryParams.sortOrderArchival,
89+
newSortColumn: column,
90+
defaultSortOrder: 'DESC',
91+
}),
92+
}),
93+
sortColumn: queryParams.sortColumnArchival,
94+
sortOrder: queryParams.sortOrderArchival,
95+
}
96+
: undefined
97+
}
7798
/>
7899
);
79100
}

src/views/domain-workflows/domain-workflows-list/domain-workflows-list.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ErrorPanel from '@/components/error-panel/error-panel';
44
import PanelSection from '@/components/panel-section/panel-section';
55
import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator';
66
import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params';
7+
import { toggleSortOrder } from '@/utils/sort-by';
78
import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config';
89
import useListWorkflows from '@/views/shared/hooks/use-list-workflows';
910
import WorkflowsList from '@/views/shared/workflows-list/workflows-list';
@@ -20,7 +21,9 @@ export default function DomainWorkflowsList({
2021
timeRangeStart,
2122
timeRangeEnd,
2223
}: Props) {
23-
const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig);
24+
const [queryParams, setQueryParams] = usePageQueryParams(
25+
domainPageQueryParamsConfig
26+
);
2427

2528
const {
2629
workflows,
@@ -77,6 +80,24 @@ export default function DomainWorkflowsList({
7780
hasNextPage={hasNextPage}
7881
fetchNextPage={fetchNextPage}
7982
isFetchingNextPage={isFetchingNextPage}
83+
sortParams={
84+
queryParams.inputType === 'search'
85+
? {
86+
onSort: (column: string) =>
87+
setQueryParams({
88+
sortColumn: column,
89+
sortOrder: toggleSortOrder({
90+
currentSortColumn: queryParams.sortColumn,
91+
currentSortOrder: queryParams.sortOrder,
92+
newSortColumn: column,
93+
defaultSortOrder: 'DESC',
94+
}),
95+
}),
96+
sortColumn: queryParams.sortColumn,
97+
sortOrder: queryParams.sortOrder,
98+
}
99+
: undefined
100+
}
80101
/>
81102
);
82103
}

src/views/shared/workflows-list/__tests__/workflows-list.test.tsx

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { render, screen } from '@/test-utils/rtl';
3+
import { render, screen, userEvent } from '@/test-utils/rtl';
44

55
import { type Props as LoaderProps } from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader.types';
66
import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items';
@@ -126,6 +126,100 @@ describe(WorkflowsList.name, () => {
126126
const loader = screen.getByTestId('mock-loader');
127127
expect(loader).toHaveTextContent('no-data');
128128
});
129+
130+
it('renders plain header cells when sortParams is not provided', () => {
131+
setup({
132+
columns: [
133+
{
134+
id: 'StartTime',
135+
name: 'Started',
136+
width: '200px',
137+
isSystem: true,
138+
sortable: true,
139+
renderCell: () => 'start',
140+
},
141+
],
142+
});
143+
144+
expect(screen.getByText('Started')).toBeInTheDocument();
145+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
146+
});
147+
148+
it('renders sortable header cells as buttons when sortParams is provided', () => {
149+
const onSort = jest.fn();
150+
setup({
151+
columns: [
152+
{
153+
id: 'StartTime',
154+
name: 'Started',
155+
width: '200px',
156+
isSystem: true,
157+
sortable: true,
158+
renderCell: () => 'start',
159+
},
160+
],
161+
sortParams: {
162+
onSort,
163+
sortColumn: '',
164+
sortOrder: 'DESC',
165+
},
166+
});
167+
168+
const button = screen.getByRole('button', {
169+
name: /Started/,
170+
});
171+
expect(button).toBeInTheDocument();
172+
});
173+
174+
it('calls onSort with the column id when a sortable header is clicked', async () => {
175+
const onSort = jest.fn();
176+
const { user } = setup({
177+
columns: [
178+
{
179+
id: 'StartTime',
180+
name: 'Started',
181+
width: '200px',
182+
isSystem: true,
183+
sortable: true,
184+
renderCell: () => 'start',
185+
},
186+
],
187+
sortParams: {
188+
onSort,
189+
sortColumn: '',
190+
sortOrder: 'DESC',
191+
},
192+
});
193+
194+
await user.click(
195+
screen.getByRole('button', {
196+
name: /Started/,
197+
})
198+
);
199+
expect(onSort).toHaveBeenCalledWith('StartTime');
200+
});
201+
202+
it('does not render non-sortable columns as buttons even when sortParams is provided', () => {
203+
setup({
204+
columns: [
205+
{
206+
id: 'WorkflowID',
207+
name: 'Workflow ID',
208+
width: '200px',
209+
isSystem: true,
210+
renderCell: (row) => row.workflowID,
211+
},
212+
],
213+
sortParams: {
214+
onSort: jest.fn(),
215+
sortColumn: '',
216+
sortOrder: 'DESC',
217+
},
218+
});
219+
220+
expect(screen.getByText('Workflow ID')).toBeInTheDocument();
221+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
222+
});
129223
});
130224

131225
function setup({
@@ -134,7 +228,9 @@ function setup({
134228
error = null,
135229
hasNextPage = false,
136230
isFetchingNextPage = false,
231+
sortParams,
137232
}: Partial<React.ComponentProps<typeof WorkflowsList>> = {}) {
233+
const user = userEvent.setup();
138234
render(
139235
<WorkflowsList
140236
workflows={workflows}
@@ -143,6 +239,8 @@ function setup({
143239
hasNextPage={hasNextPage}
144240
fetchNextPage={jest.fn()}
145241
isFetchingNextPage={isFetchingNextPage}
242+
sortParams={sortParams}
146243
/>
147244
);
245+
return { user };
148246
}

src/views/shared/workflows-list/config/workflows-list-columns.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ const workflowsListColumnsConfig: ReadonlyArray<WorkflowsListColumnConfig> = [
3838
match: (name) => name === 'StartTime',
3939
name: 'Started',
4040
width: 'minmax(100px, 200px)',
41+
sortable: true,
4142
renderCell: (row) =>
4243
createElement(FormattedDate, { timestampMs: row.startTime }),
4344
},
4445
{
4546
match: (name) => name === 'CloseTime',
4647
name: 'Ended',
4748
width: 'minmax(100px, 200px)',
49+
sortable: true,
4850
renderCell: (row) =>
4951
createElement(FormattedDate, { timestampMs: row.closeTime }),
5052
},

src/views/shared/workflows-list/helpers/get-workflows-list-column-from-search-attribute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default function getWorkflowsListColumnFromSearchAttribute(
2424
name: config?.name ?? attributeName,
2525
width: config?.width ?? DEFAULT_WORKFLOWS_LIST_COLUMN_WIDTH,
2626
isSystem,
27+
sortable: config?.sortable,
2728
renderCell: config
2829
? (row) => config.renderCell(row, attributeName)
2930
: (row) => {

src/views/shared/workflows-list/workflows-list.styles.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ export const styled = {
4141
paddingRight: $theme.sizing.scale600,
4242
whiteSpace: 'wrap',
4343
})),
44+
SortableHeaderCell: createStyled('button', ({ $theme }: { $theme: Theme }) => ({
45+
...$theme.typography.LabelSmall,
46+
display: 'flex',
47+
alignItems: 'center',
48+
columnGap: $theme.sizing.scale300,
49+
color: $theme.colors.contentSecondary,
50+
paddingTop: $theme.sizing.scale400,
51+
paddingBottom: $theme.sizing.scale400,
52+
paddingLeft: $theme.sizing.scale600,
53+
paddingRight: $theme.sizing.scale600,
54+
whiteSpace: 'nowrap' as const,
55+
overflow: 'hidden',
56+
textOverflow: 'ellipsis',
57+
background: 'none',
58+
border: 'none',
59+
cursor: 'pointer',
60+
':hover': {
61+
color: $theme.colors.contentPrimary,
62+
},
63+
})),
4464
GridRow: createStyled<'a', { $gridTemplateColumns: string }>(
4565
'a',
4666
({ $theme, $gridTemplateColumns }) => ({

src/views/shared/workflows-list/workflows-list.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useMemo } from 'react';
22

3+
import ChevronDown from 'baseui/icon/chevron-down';
4+
import ChevronUp from 'baseui/icon/chevron-up';
35
import isNil from 'lodash/isNil';
46
import NextLink from 'next/link';
57

68
import TableInfiniteScrollLoader from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader';
79

810
import { styled } from './workflows-list.styles';
9-
import { type Props } from './workflows-list.types';
11+
import { type Props, type WorkflowsListColumn } from './workflows-list.types';
1012

1113
export default function WorkflowsList({
1214
workflows,
@@ -15,6 +17,7 @@ export default function WorkflowsList({
1517
hasNextPage,
1618
fetchNextPage,
1719
isFetchingNextPage,
20+
sortParams,
1821
}: Props) {
1922
const gridTemplateColumns = useMemo(
2023
() => columns.map((col) => col.width).join(' '),
@@ -28,9 +31,39 @@ export default function WorkflowsList({
2831
<styled.ScrollArea>
2932
<styled.Container>
3033
<styled.GridHeader $gridTemplateColumns={gridTemplateColumns}>
31-
{columns.map((col) => (
32-
<styled.HeaderCell key={col.id}>{col.name}</styled.HeaderCell>
33-
))}
34+
{columns.map((col: WorkflowsListColumn) => {
35+
if (col.sortable && sortParams) {
36+
const isActive = sortParams.sortColumn === col.id;
37+
38+
let SortIcon = null;
39+
if (isActive && sortParams.sortOrder === 'ASC') {
40+
SortIcon = ChevronUp;
41+
} else if (isActive && sortParams.sortOrder === 'DESC') {
42+
SortIcon = ChevronDown;
43+
}
44+
45+
return (
46+
<styled.SortableHeaderCell
47+
key={col.id}
48+
onClick={() => sortParams.onSort(col.id)}
49+
aria-label={`${col.name}, ${isActive ? `sorted ${sortParams.sortOrder}` : 'not sorted'}`}
50+
>
51+
{col.name}
52+
{SortIcon && (
53+
<SortIcon
54+
size="16px"
55+
aria-hidden="true"
56+
role="presentation"
57+
/>
58+
)}
59+
</styled.SortableHeaderCell>
60+
);
61+
}
62+
63+
return (
64+
<styled.HeaderCell key={col.id}>{col.name}</styled.HeaderCell>
65+
);
66+
})}
3467
</styled.GridHeader>
3568
{hasWorkflows &&
3669
workflows.map((workflow, index) => (

src/views/shared/workflows-list/workflows-list.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type React from 'react';
22

33
import { type IndexedValueType } from '@/__generated__/proto-ts/uber/cadence/api/v1/IndexedValueType';
4+
import { type SortOrder } from '@/utils/sort-by';
45
import { type DomainWorkflow } from '@/views/domain-page/domain-page.types';
56

67
export type WorkflowsListColumnConfig = {
78
match: (attributeName: string, attributeType: IndexedValueType) => boolean;
89
name?: string;
910
width?: string;
11+
sortable?: boolean;
1012
renderCell: (row: DomainWorkflow, attributeName: string) => React.ReactNode;
1113
};
1214

@@ -15,14 +17,22 @@ export type WorkflowsListColumn = {
1517
name: string;
1618
width: string;
1719
isSystem: boolean;
20+
sortable?: boolean;
1821
renderCell: (row: DomainWorkflow) => React.ReactNode;
1922
};
2023

24+
type SortParams = {
25+
onSort: (column: string) => void;
26+
sortColumn: string;
27+
sortOrder: SortOrder;
28+
};
29+
2130
export type Props = {
2231
workflows: Array<DomainWorkflow>;
2332
columns: Array<WorkflowsListColumn>;
2433
error: Error | null;
2534
hasNextPage: boolean;
2635
fetchNextPage: () => void;
2736
isFetchingNextPage: boolean;
37+
sortParams?: SortParams;
2838
};

0 commit comments

Comments
 (0)