Skip to content

Commit b92c082

Browse files
committed
Introduce profile card on assignee hover in Jira Dashboard #300
Signed-off-by: enaysaa saachi.nayyer@ericsson.com
1 parent 2a2afb8 commit b92c082

4 files changed

Lines changed: 238 additions & 36 deletions

File tree

.changeset/itchy-aliens-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@axis-backstage/plugin-jira-dashboard': major
3+
---
4+
5+
Introduce a profile card that appears when hovering over an assignee's name in any table within the Jira Dashboard.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { AssigneeCell } from './AssigneeCell';
3+
import { ThemeProvider, createTheme } from '@mui/material/styles';
4+
import { MemoryRouter } from 'react-router-dom';
5+
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';
6+
7+
jest.mock('@backstage/plugin-catalog-react', () => ({
8+
EntityPeekAheadPopover: ({ children }: any) => (
9+
<div data-testid="peek-ahead">{children}</div>
10+
),
11+
}));
12+
13+
const renderWithProviders = (ui: React.ReactElement) =>
14+
render(
15+
<MemoryRouter>
16+
<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>
17+
</MemoryRouter>,
18+
);
19+
20+
describe('AssigneeCell', () => {
21+
const baseAssignee: Issue['fields']['assignee'] = {
22+
name: 'jane.doe',
23+
key: 'jdoe',
24+
self: '',
25+
displayName: 'Jane Doe',
26+
avatarUrls: { '48x48': 'http://example.com/avatar.jpg' },
27+
};
28+
29+
it('renders displayName with EntityPeekAheadPopover and correct link when assignee is valid', () => {
30+
renderWithProviders(<AssigneeCell assignee={baseAssignee} />);
31+
expect(screen.getByTestId('peek-ahead')).toBeInTheDocument();
32+
expect(screen.getByRole('link')).toHaveAttribute(
33+
'href',
34+
'/catalog/default/user/jane.doe',
35+
);
36+
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
37+
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
38+
});
39+
40+
it('renders "Unassigned" when assignee name is an empty string', () => {
41+
renderWithProviders(
42+
<AssigneeCell
43+
assignee={{
44+
...baseAssignee,
45+
name: '',
46+
displayName: '',
47+
}}
48+
/>,
49+
);
50+
51+
expect(screen.queryByTestId('peek-ahead')).not.toBeInTheDocument();
52+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
53+
expect(screen.getByText('Unassigned')).toBeInTheDocument();
54+
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
55+
});
56+
57+
const unassignedNames = ['unassigned', 'Unassigned', 'UNASSIGNED'];
58+
59+
test.each(unassignedNames)(
60+
'renders "Unassigned" when assignee name is "%s"',
61+
name => {
62+
renderWithProviders(
63+
<AssigneeCell
64+
assignee={{
65+
name,
66+
displayName: '',
67+
key: '',
68+
self: '',
69+
avatarUrls: {
70+
'48x48': '',
71+
},
72+
}}
73+
/>,
74+
);
75+
76+
expect(screen.queryByTestId('peek-ahead')).not.toBeInTheDocument();
77+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
78+
expect(screen.getByText('Unassigned')).toBeInTheDocument();
79+
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
80+
},
81+
);
82+
83+
it('renders name if displayName is missing', () => {
84+
renderWithProviders(
85+
<AssigneeCell
86+
assignee={{
87+
name: 'john.smith',
88+
key: 'jsmith',
89+
self: '',
90+
displayName: '',
91+
avatarUrls: { '48x48': '' },
92+
}}
93+
/>,
94+
);
95+
96+
expect(screen.getByText('john.smith')).toBeInTheDocument();
97+
expect(screen.getByRole('link')).toHaveAttribute(
98+
'href',
99+
'/catalog/default/user/john.smith',
100+
);
101+
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
102+
});
103+
104+
it('renders "Unassigned" when assignee.name and displayName are both empty (no fallback to key)', () => {
105+
renderWithProviders(
106+
<AssigneeCell
107+
assignee={{
108+
key: 'userkey123',
109+
name: '',
110+
self: '',
111+
displayName: '',
112+
avatarUrls: { '48x48': '' },
113+
}}
114+
/>,
115+
);
116+
117+
expect(screen.getByText('Unassigned')).toBeInTheDocument();
118+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
119+
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
120+
});
121+
122+
it('renders nothing when assignee is undefined', () => {
123+
const { container } = renderWithProviders(
124+
<AssigneeCell assignee={undefined} />,
125+
);
126+
expect(container).toBeEmptyDOMElement();
127+
});
128+
129+
it('normalizes email-like assignee name (fridaja@backstage) to username', () => {
130+
renderWithProviders(
131+
<AssigneeCell
132+
assignee={{
133+
...baseAssignee,
134+
name: 'fridaja@backstage',
135+
displayName: 'Frida J',
136+
}}
137+
/>,
138+
);
139+
140+
expect(screen.getByRole('link')).toHaveAttribute(
141+
'href',
142+
'/catalog/default/user/fridaja',
143+
);
144+
expect(screen.getByText('Frida J')).toBeInTheDocument();
145+
});
146+
147+
it('normalizes real email (alice@example.com) to username', () => {
148+
renderWithProviders(
149+
<AssigneeCell
150+
assignee={{
151+
...baseAssignee,
152+
name: 'alice@example.com',
153+
displayName: 'Alice Example',
154+
}}
155+
/>,
156+
);
157+
158+
expect(screen.getByRole('link')).toHaveAttribute(
159+
'href',
160+
'/catalog/default/user/alice',
161+
);
162+
expect(screen.getByText('Alice Example')).toBeInTheDocument();
163+
});
164+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Avatar, Link } from '@backstage/core-components';
2+
import Typography from '@mui/material/Typography';
3+
import Stack from '@mui/material/Stack';
4+
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';
5+
import { EntityPeekAheadPopover } from '@backstage/plugin-catalog-react';
6+
import { stringifyEntityRef } from '@backstage/catalog-model';
7+
8+
type Props = {
9+
assignee?: Issue['fields']['assignee'];
10+
};
11+
12+
const normalizeAssigneeName = (name: string): string => {
13+
if (!name) return '';
14+
if (name.includes('@')) {
15+
return name.split('@')[0];
16+
}
17+
return name;
18+
};
19+
export const AssigneeCell = ({ assignee }: Props) => {
20+
if (!assignee) {
21+
return null;
22+
}
23+
24+
if (!assignee.name || assignee.name.toLowerCase() === 'unassigned') {
25+
return (
26+
<Stack
27+
direction="row"
28+
gap={1}
29+
alignItems="center"
30+
data-testid="assignee-avatar"
31+
>
32+
<Avatar picture="" customStyles={{ width: 35, height: 35 }} />
33+
<Typography variant="body2">Unassigned</Typography>
34+
</Stack>
35+
);
36+
}
37+
38+
const entityRef = {
39+
kind: 'user',
40+
namespace: 'default',
41+
name: normalizeAssigneeName(assignee.name),
42+
};
43+
44+
return (
45+
<EntityPeekAheadPopover entityRef={stringifyEntityRef(entityRef)}>
46+
<Link
47+
to={`/catalog/${entityRef.namespace}/${entityRef.kind}/${entityRef.name}`}
48+
>
49+
<Stack
50+
direction="row"
51+
gap={1}
52+
alignItems="center"
53+
data-testid="assignee-avatar"
54+
>
55+
<Avatar
56+
picture={assignee.avatarUrls?.['48x48'] || ''}
57+
customStyles={{ width: 35, height: 35 }}
58+
/>
59+
<Typography noWrap variant="body2">
60+
{assignee.displayName || assignee.name}
61+
</Typography>
62+
</Stack>
63+
</Link>
64+
</EntityPeekAheadPopover>
65+
);
66+
};

plugins/jira-dashboard/src/components/JiraTable/columns.tsx

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { Avatar, Link, TableColumn } from '@backstage/core-components';
1+
import { Link, TableColumn } from '@backstage/core-components';
22
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';
33
import Typography from '@mui/material/Typography';
44
import { getIssueUrl } from '../../lib';
5-
import Stack from '@mui/material/Stack';
65
import Chip from '@mui/material/Chip';
76
import Box from '@mui/material/Box';
87
import { DateTime } from 'luxon';
8+
import { AssigneeCell } from './cells/AssigneeCell';
99

1010
export const columnKey: TableColumn<Issue> = {
1111
title: 'Key',
@@ -119,42 +119,9 @@ export const columnAssignee: TableColumn<Issue> = {
119119
width: '20%',
120120

121121
render: (issue: Partial<Issue>) => {
122-
if (issue.fields?.assignee?.displayName) {
123-
return (
124-
<Stack direction="row" gap={1} alignItems="center" mb={1}>
125-
<Avatar
126-
picture={issue.fields?.assignee?.avatarUrls['48x48'] || ''}
127-
customStyles={{
128-
width: 35,
129-
height: 35,
130-
}}
131-
/>
132-
<Typography variant="body2">
133-
{issue.fields.assignee.displayName}
134-
</Typography>
135-
</Stack>
136-
);
137-
} else if (issue.fields?.assignee?.name) {
138-
return (
139-
<Typography variant="body2">
140-
{issue.fields.assignee.name.split('@')[0]}
141-
</Typography>
142-
);
143-
} else if (issue.fields?.assignee?.key) {
144-
return (
145-
<Typography variant="body2">{issue.fields.assignee.key}</Typography>
146-
);
147-
}
148-
return (
149-
<Typography
150-
sx={{ color: theme => theme.palette.text.disabled }}
151-
color="divider"
152-
variant="body2"
153-
/>
154-
);
122+
return <AssigneeCell assignee={issue.fields?.assignee} />;
155123
},
156124
};
157-
158125
export const columnUpdated: TableColumn<Issue> = {
159126
title: 'Updated',
160127
field: 'fields.updated',

0 commit comments

Comments
 (0)