Skip to content

Commit 520e085

Browse files
harsh-vadorclaude
andcommitted
feat(ui): give the inbox task list a way through a long queue
The list was a flat, undifferentiated stack: every card repeated the entity type it belonged to, and the only way to find a task was to scroll. It now groups by task type, most urgent type first — a live test failure, then a request blocking someone, then metadata hygiene — with a count per group. A toolbar above it searches, switches the grouping off, and narrows to the types the queue actually contains, rather than offering the whole enum. Search is served: the text reaches `/tasks/visible` as `q` once typing settles, so it matches tasks that were never loaded. The type filter is not — the scoped list endpoints have no `type` param — so it narrows the loaded pages, and says so at the call site. Grouping likewise covers what is loaded: the server pages by cursor, not by type. Each card drops the type badge, now carried by its group header, and gains the asset it concerns, which is what distinguishes two otherwise identical rows. `taskDetail.utils` no longer reads the tier through the table utils. That module reaches the customization and permission layers at import time, which pulled a React Query client into the list bundle and broke its tests; the tier prefix is read from the shared constants instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fd030b2 commit 520e085

10 files changed

Lines changed: 760 additions & 69 deletions

File tree

openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxTaskListItem.test.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ jest.mock('components/common/ProfilePicture/ProfilePicture', () => ({
2020
default: () => <div />,
2121
}));
2222

23-
jest.mock('../taskList.utils', () => ({
24-
formatEntityType: (type?: string) => type ?? '',
25-
}));
26-
2723
jest.mock('../taskTitle.utils', () => ({
2824
getTaskTitle: (task: {
2925
displayName?: string;
@@ -38,8 +34,9 @@ jest.mock('../taskTitle.utils', () => ({
3834
},
3935
}));
4036

41-
jest.mock('../inbox.utils', () => ({
42-
formatInboxDate: () => '13 May, 2026',
37+
jest.mock('utils/EntityNameUtils', () => ({
38+
getEntityName: (ref: { displayName?: string; name?: string }) =>
39+
ref?.displayName ?? ref?.name ?? '',
4340
}));
4441

4542
jest.mock('@openmetadata/ui-core-components', () => ({
@@ -81,7 +78,7 @@ const task = {
8178
id: 't1',
8279
taskId: '11345',
8380
displayName: 'Data Access Request for RF3',
84-
about: { type: 'Table' },
81+
about: { type: 'Table', name: 'dim_customers' },
8582
createdBy: { id: 'u1', name: 'olivia', displayName: 'Olivia Rhye' },
8683
assignees: [
8784
{ id: 'a1', name: 'one' },
@@ -92,17 +89,35 @@ const task = {
9289
} as unknown as Task;
9390

9491
describe('InboxTaskListItem', () => {
95-
it('renders id, type, title, requester, date and comment count', () => {
92+
it('renders the id, title, requester, asset and comment count', () => {
9693
render(<InboxTaskListItem task={task} onClick={jest.fn()} />);
9794

9895
expect(screen.getByText('#11345')).toBeInTheDocument();
99-
expect(screen.getByText('Table')).toBeInTheDocument();
10096
expect(screen.getByText('Data Access Request for RF3')).toBeInTheDocument();
10197
expect(screen.getByText('Olivia Rhye')).toBeInTheDocument();
102-
expect(screen.getByText('13 May, 2026')).toBeInTheDocument();
98+
expect(screen.getByText('dim_customers')).toBeInTheDocument();
10399
expect(screen.getByText('2')).toBeInTheDocument();
104100
});
105101

102+
// The type is carried by the list's group header, so repeating it on every
103+
// card would be noise.
104+
it('does not repeat the task type on the card', () => {
105+
render(<InboxTaskListItem task={task} onClick={jest.fn()} />);
106+
107+
expect(screen.queryByText('Table')).not.toBeInTheDocument();
108+
});
109+
110+
it('leaves the asset chip off a task that names no entity', () => {
111+
render(
112+
<InboxTaskListItem
113+
task={{ ...task, about: undefined } as Task}
114+
onClick={jest.fn()}
115+
/>
116+
);
117+
118+
expect(screen.queryByText('dim_customers')).not.toBeInTheDocument();
119+
});
120+
106121
it('composes a title for a task whose name is only the taskId', () => {
107122
render(
108123
<InboxTaskListItem

openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxTaskListItem.tsx

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import React from 'react';
1818
import { useTranslation } from 'react-i18next';
1919
import ProfilePicture from '../../../../../components/common/ProfilePicture/ProfilePicture';
2020
import { Task } from '../../../../../generated/entity/tasks/task';
21-
import { formatInboxDate } from '../inbox.utils';
22-
import { formatEntityType } from '../taskList.utils';
21+
import { getEntityName } from '../../../../../utils/EntityNameUtils';
2322
import { getTaskTitle } from '../taskTitle.utils';
2423

2524
export interface InboxTaskListItemProps {
@@ -32,19 +31,59 @@ const Dot: React.FC = () => (
3231
<span className="tw:h-1 tw:w-1 tw:shrink-0 tw:rounded-full tw:bg-utility-gray-blue-300" />
3332
);
3433

34+
/** The card's second line: task id, who raised it, and the asset it concerns. */
35+
const TaskCardMeta: React.FC<{ task: Task }> = ({ task }) => {
36+
const requester = task.createdBy;
37+
const requesterName = requester?.displayName ?? requester?.name;
38+
const assetName = task.about ? getEntityName(task.about) : '';
39+
40+
return (
41+
<Box align="center" className="tw:flex-wrap tw:gap-x-2 tw:gap-y-1">
42+
<Typography
43+
className="tw:font-mono tw:text-utility-blue-dark-500"
44+
size="text-xs"
45+
weight="medium">
46+
{`#${task.taskId ?? ''}`}
47+
</Typography>
48+
{requesterName && (
49+
<>
50+
<Dot />
51+
<ProfilePicture
52+
displayName={requester?.name}
53+
name={requester?.name ?? ''}
54+
width="18"
55+
/>
56+
<Typography
57+
className="tw:text-secondary"
58+
size="text-xs"
59+
weight="medium">
60+
{requesterName}
61+
</Typography>
62+
</>
63+
)}
64+
{assetName && (
65+
<Badge
66+
className="tw:ml-auto tw:shrink-0 tw:font-mono"
67+
size="sm"
68+
type="modern">
69+
{assetName}
70+
</Badge>
71+
)}
72+
</Box>
73+
);
74+
};
75+
3576
/**
36-
* Compact task card in the Inbox Tasks tab: the title + comment count on top,
37-
* then a single meta row of id · type · requester · date. Cards are spaced,
38-
* lightly bordered, and highlight when selected (matches the figma).
77+
* Compact task card in the Inbox Tasks tab: the title and comment count on top,
78+
* then a meta row of id · requester · the asset it concerns. The task's type is
79+
* carried by the list's group header rather than repeated on every card.
3980
*/
4081
const InboxTaskListItem: React.FC<InboxTaskListItemProps> = ({
4182
task,
4283
isActive,
4384
onClick,
4485
}) => {
4586
const { t } = useTranslation();
46-
const entityType = formatEntityType(task.about?.type);
47-
const createdByName = task.createdBy?.displayName ?? task.createdBy?.name;
4887
const commentCount = task.commentCount ?? task.comments?.length ?? 0;
4988
// Titleless tasks (governance workflows) carry the taskId as their name, so
5089
// getTaskTitle composes a title from the task type and the entity it is about
@@ -75,7 +114,7 @@ const InboxTaskListItem: React.FC<InboxTaskListItemProps> = ({
75114
{taskTitle && (
76115
<Typography
77116
className="tw:text-left tw:text-primary-900"
78-
ellipsis={{ rows: 1 }}
117+
ellipsis={{ rows: 2 }}
79118
size="text-sm"
80119
weight="medium">
81120
{taskTitle}
@@ -95,39 +134,7 @@ const InboxTaskListItem: React.FC<InboxTaskListItemProps> = ({
95134
</Box>
96135
</Box>
97136

98-
<Box align="center" className="tw:flex-wrap tw:gap-x-2 tw:gap-y-1">
99-
<Typography
100-
className="tw:text-utility-blue-dark-500"
101-
size="text-xs"
102-
weight="medium">
103-
{`#${task.taskId ?? ''}`}
104-
</Typography>
105-
{entityType && (
106-
<Badge className="tw:shrink-0" size="sm" type="modern">
107-
{entityType}
108-
</Badge>
109-
)}
110-
{createdByName && (
111-
<>
112-
<Dot />
113-
<ProfilePicture
114-
displayName={task.createdBy?.name}
115-
name={task.createdBy?.name ?? ''}
116-
width="18"
117-
/>
118-
<Typography
119-
className="tw:text-secondary"
120-
size="text-xs"
121-
weight="medium">
122-
{createdByName}
123-
</Typography>
124-
</>
125-
)}
126-
<Dot />
127-
<Typography className="tw:text-secondary" size="text-xs">
128-
{formatInboxDate(task.createdAt)}
129-
</Typography>
130-
</Box>
137+
<TaskCardMeta task={task} />
131138
</Box>
132139
);
133140
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright 2026 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
import { fireEvent, render, screen } from '@testing-library/react';
15+
import { ReactNode } from 'react';
16+
17+
interface MockFilterSelectProps {
18+
label: string;
19+
options: { value: string; label: ReactNode; count?: number }[];
20+
selectedValues: string[];
21+
onChange: (values: string[]) => void;
22+
'data-testid'?: string;
23+
}
24+
25+
// The real control is a popover-driven listbox with its own suite; here each
26+
// option is a button so a test can pick one.
27+
jest.mock('@openmetadata/ui-core-components', () => ({
28+
Box: ({
29+
children,
30+
...rest
31+
}: {
32+
children?: ReactNode;
33+
'data-testid'?: string;
34+
}) => <div data-testid={rest['data-testid']}>{children}</div>,
35+
FilterSelect: ({
36+
options,
37+
selectedValues,
38+
onChange,
39+
'data-testid': testId,
40+
}: MockFilterSelectProps) => (
41+
<div data-selected={selectedValues.join(',')} data-testid={testId}>
42+
{options.map((option) => (
43+
<button
44+
data-count={option.count}
45+
data-testid={`${testId}-${option.value}`}
46+
key={option.value}
47+
onClick={() => onChange([option.value])}>
48+
{option.label}
49+
</button>
50+
))}
51+
</div>
52+
),
53+
Input: ({
54+
value,
55+
onChange,
56+
inputDataTestId,
57+
}: {
58+
value?: string;
59+
onChange?: (value: string) => void;
60+
inputDataTestId?: string;
61+
}) => (
62+
<input
63+
aria-label="search"
64+
data-testid={inputDataTestId}
65+
value={value}
66+
onChange={(event) => onChange?.(event.target.value)}
67+
/>
68+
),
69+
SearchInputIcon: () => <span />,
70+
}));
71+
72+
jest.mock('react-i18next', () => ({
73+
useTranslation: () => ({ t: (key: string) => key }),
74+
}));
75+
76+
import { Task, TaskType } from '../../../../../generated/entity/tasks/task';
77+
import InboxTaskListToolbar from './InboxTaskListToolbar';
78+
79+
const TASKS = [
80+
{ id: 't1', type: TaskType.TagUpdate },
81+
{ id: 't2', type: TaskType.TagUpdate },
82+
{ id: 't3', type: TaskType.TestCaseResolution },
83+
] as unknown as Task[];
84+
85+
const props = {
86+
search: '',
87+
onSearchChange: jest.fn(),
88+
grouping: 'type' as const,
89+
onGroupingChange: jest.fn(),
90+
typeFilter: [],
91+
onTypeFilterChange: jest.fn(),
92+
tasks: TASKS,
93+
};
94+
95+
beforeEach(() => jest.clearAllMocks());
96+
97+
describe('InboxTaskListToolbar', () => {
98+
it('reports what was typed in the search box', () => {
99+
render(<InboxTaskListToolbar {...props} />);
100+
101+
fireEvent.change(screen.getByTestId('inbox-tasks-search'), {
102+
target: { value: 'customer' },
103+
});
104+
105+
expect(props.onSearchChange).toHaveBeenCalledWith('customer');
106+
});
107+
108+
// Offering the full enum would list types the queue does not contain.
109+
it('offers only the types present in the queue, with their counts', () => {
110+
render(<InboxTaskListToolbar {...props} />);
111+
112+
expect(
113+
screen.getByTestId('inbox-tasks-type-filter-TagUpdate')
114+
).toHaveAttribute('data-count', '2');
115+
expect(
116+
screen.getByTestId('inbox-tasks-type-filter-TestCaseResolution')
117+
).toHaveAttribute('data-count', '1');
118+
expect(
119+
screen.queryByTestId('inbox-tasks-type-filter-DataAccessRequest')
120+
).not.toBeInTheDocument();
121+
});
122+
123+
it('reports a chosen type', () => {
124+
render(<InboxTaskListToolbar {...props} />);
125+
126+
fireEvent.click(screen.getByTestId('inbox-tasks-type-filter-TagUpdate'));
127+
128+
expect(props.onTypeFilterChange).toHaveBeenCalledWith([TaskType.TagUpdate]);
129+
});
130+
131+
it('reports a grouping change', () => {
132+
render(<InboxTaskListToolbar {...props} />);
133+
134+
fireEvent.click(screen.getByTestId('inbox-tasks-group-by-none'));
135+
136+
expect(props.onGroupingChange).toHaveBeenCalledWith('none');
137+
});
138+
139+
it('shows the active grouping as selected', () => {
140+
render(<InboxTaskListToolbar {...props} />);
141+
142+
expect(screen.getByTestId('inbox-tasks-group-by')).toHaveAttribute(
143+
'data-selected',
144+
'type'
145+
);
146+
});
147+
148+
it('offers no types for an empty queue', () => {
149+
render(<InboxTaskListToolbar {...props} tasks={[]} />);
150+
151+
expect(screen.getByTestId('inbox-tasks-type-filter')).toBeEmptyDOMElement();
152+
});
153+
});

0 commit comments

Comments
 (0)