Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/itchy-aliens-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@axis-backstage/plugin-jira-dashboard': major
---

Introduce a profile card that appears when hovering over an assignee's name in any table within the Jira Dashboard.
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { render, screen } from '@testing-library/react';
import { AssigneeCell } from './AssigneeCell';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { MemoryRouter } from 'react-router-dom';
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';

jest.mock('@backstage/plugin-catalog-react', () => ({
EntityPeekAheadPopover: ({ children }: any) => (
<div data-testid="peek-ahead">{children}</div>
),
}));

const renderWithProviders = (ui: React.ReactElement) =>
render(
<MemoryRouter>
<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>
</MemoryRouter>,
);

describe('AssigneeCell', () => {
const baseAssignee: Issue['fields']['assignee'] = {
name: 'jane.doe',
key: 'jdoe',
self: '',
displayName: 'Jane Doe',
avatarUrls: { '48x48': 'http://example.com/avatar.jpg' },
};

it('renders displayName with EntityPeekAheadPopover and correct link when assignee is valid', () => {
renderWithProviders(<AssigneeCell assignee={baseAssignee} />);
expect(screen.getByTestId('peek-ahead')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/jane.doe',
);
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
});

it('renders "Unassigned" when assignee name is an empty string', () => {
renderWithProviders(
<AssigneeCell
assignee={{
...baseAssignee,
name: '',
displayName: '',
}}
/>,
);

expect(screen.queryByTestId('peek-ahead')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Unassigned')).toBeInTheDocument();
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
});

const unassignedNames = ['unassigned', 'Unassigned', 'UNASSIGNED'];

test.each(unassignedNames)(
'renders "Unassigned" when assignee name is "%s"',
name => {
renderWithProviders(
<AssigneeCell
assignee={{
name,
displayName: '',
key: '',
self: '',
avatarUrls: {
'48x48': '',
},
}}
/>,
);

expect(screen.queryByTestId('peek-ahead')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Unassigned')).toBeInTheDocument();
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
},
);

it('renders name if displayName is missing', () => {
renderWithProviders(
<AssigneeCell
assignee={{
name: 'john.smith',
key: 'jsmith',
self: '',
displayName: '',
avatarUrls: { '48x48': '' },
}}
/>,
);

expect(screen.getByText('john.smith')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/john.smith',
);
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
});

it('renders "Unassigned" when assignee.name and displayName are both empty (no fallback to key)', () => {
renderWithProviders(
<AssigneeCell
assignee={{
key: 'userkey123',
name: '',
self: '',
displayName: '',
avatarUrls: { '48x48': '' },
}}
/>,
);

expect(screen.getByText('Unassigned')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByTestId('assignee-avatar')).toBeInTheDocument();
});

it('renders nothing when assignee is undefined', () => {
const { container } = renderWithProviders(
<AssigneeCell assignee={undefined} />,
);
expect(container).toBeEmptyDOMElement();
});

it('normalizes email-like assignee name (fridaja@backstage) to username', () => {
renderWithProviders(
<AssigneeCell
assignee={{
...baseAssignee,
name: 'fridaja@backstage',
displayName: 'Frida J',
}}
/>,
);

expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/fridaja',
);
expect(screen.getByText('Frida J')).toBeInTheDocument();
});

it('normalizes real email (alice@example.com) to username', () => {
renderWithProviders(
<AssigneeCell
assignee={{
...baseAssignee,
name: 'alice@example.com',
displayName: 'Alice Example',
}}
/>,
);

expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/alice',
);
expect(screen.getByText('Alice Example')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Avatar, Link } from '@backstage/core-components';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';
import { EntityPeekAheadPopover } from '@backstage/plugin-catalog-react';
import { stringifyEntityRef } from '@backstage/catalog-model';

type Props = {
assignee?: Issue['fields']['assignee'];
Comment thread
fridajac marked this conversation as resolved.
};

const normalizeAssigneeName = (name: string): string => {
if (!name) return '';
if (name.includes('@')) {
return name.split('@')[0];
}
return name;
};
export const AssigneeCell = ({ assignee }: Props) => {
if (!assignee) {
return null;
}

if (!assignee.name || assignee.name.toLowerCase() === 'unassigned') {
return (
<Stack
direction="row"
gap={1}
alignItems="center"
data-testid="assignee-avatar"
>
<Avatar picture="" customStyles={{ width: 35, height: 35 }} />
<Typography variant="body2">Unassigned</Typography>
</Stack>
);
}

const entityRef = {
kind: 'user',
namespace: 'default',
name: normalizeAssigneeName(assignee.name),
};

return (
<EntityPeekAheadPopover entityRef={stringifyEntityRef(entityRef)}>
<Link
to={`/catalog/${entityRef.namespace}/${entityRef.kind}/${entityRef.name}`}
>
<Stack
direction="row"
gap={1}
alignItems="center"
data-testid="assignee-avatar"
>
<Avatar
picture={assignee.avatarUrls?.['48x48'] || ''}
customStyles={{ width: 35, height: 35 }}
/>
<Typography noWrap variant="body2">
{assignee.displayName || assignee.name}
</Typography>
</Stack>
</Link>
</EntityPeekAheadPopover>
);
};
39 changes: 3 additions & 36 deletions plugins/jira-dashboard/src/components/JiraTable/columns.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Avatar, Link, TableColumn } from '@backstage/core-components';
import { Link, TableColumn } from '@backstage/core-components';
import { Issue } from '@axis-backstage/plugin-jira-dashboard-common';
import Typography from '@mui/material/Typography';
import { getIssueUrl } from '../../lib';
import Stack from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import Box from '@mui/material/Box';
import { DateTime } from 'luxon';
import { AssigneeCell } from './cells/AssigneeCell';

export const columnKey: TableColumn<Issue> = {
title: 'Key',
Expand Down Expand Up @@ -119,42 +119,9 @@ export const columnAssignee: TableColumn<Issue> = {
width: '20%',

render: (issue: Partial<Issue>) => {
if (issue.fields?.assignee?.displayName) {
return (
<Stack direction="row" gap={1} alignItems="center" mb={1}>
<Avatar
picture={issue.fields?.assignee?.avatarUrls['48x48'] || ''}
customStyles={{
width: 35,
height: 35,
}}
/>
<Typography variant="body2">
{issue.fields.assignee.displayName}
</Typography>
</Stack>
);
} else if (issue.fields?.assignee?.name) {
return (
<Typography variant="body2">
{issue.fields.assignee.name.split('@')[0]}
</Typography>
);
} else if (issue.fields?.assignee?.key) {
return (
<Typography variant="body2">{issue.fields.assignee.key}</Typography>
);
}
return (
<Typography
sx={{ color: theme => theme.palette.text.disabled }}
color="divider"
variant="body2"
/>
);
return <AssigneeCell assignee={issue.fields?.assignee} />;
},
};

export const columnUpdated: TableColumn<Issue> = {
title: 'Updated',
field: 'fields.updated',
Expand Down