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/fix-vacation-calendar-user-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@axis-backstage/plugin-vacation-calendar': patch
---

Fix broken user links in the vacation calendar. Users are now matched by email instead of metadata.name, and links use EntityRefLink for correct catalog URLs. Unresolved users render without a link instead of navigating to `/user/undefined`. Catalog queries now paginate to support large groups.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"availabilityView": "String",
"error": { "@odata.type": "microsoft.graph.freeBusyError" },
"scheduleId": "sophie",
"scheduleId": "sophie@mail.com",
"scheduleItems": [
{
"isPrivate": false,
Expand Down Expand Up @@ -89,7 +89,7 @@
{
"availabilityView": "String",
"error": { "@odata.type": "microsoft.graph.freeBusyError" },
"scheduleId": "rascal",
"scheduleId": "rascal@mail.com",
"scheduleItems": [
{
"isPrivate": false,
Expand Down Expand Up @@ -158,7 +158,7 @@
{
"availabilityView": "String",
"error": { "@odata.type": "microsoft.graph.freeBusyError" },
"scheduleId": "buster",
"scheduleId": "buster@mail.com",
"scheduleItems": [
{
"isPrivate": false,
Expand Down Expand Up @@ -209,7 +209,7 @@
{
"availabilityView": "String",
"error": { "@odata.type": "microsoft.graph.freeBusyError" },
"scheduleId": "jasmine",
"scheduleId": "jasmine@mail.com",
"scheduleItems": [
{
"isPrivate": false,
Expand Down
24 changes: 23 additions & 1 deletion plugins/vacation-calendar/dev/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import {
CatalogApi,
catalogApiRef,
EntityProvider,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { vacationCalendarPlugin } from '../src/plugin';
import { fetchApiRef, microsoftAuthApiRef } from '@backstage/core-plugin-api';
import {
attachComponentData,
fetchApiRef,
microsoftAuthApiRef,
} from '@backstage/core-plugin-api';
import { GetEntitiesResponse } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { VacationCalendarPage } from '@axis-backstage/plugin-vacation-calendar';
Expand All @@ -18,8 +23,21 @@ const catalogApi: Partial<CatalogApi> = {
async getEntities(): Promise<GetEntitiesResponse> {
return { items: mockEntities as UserEntity[] };
},
async queryEntities() {
return {
items: mockEntities as UserEntity[],
totalItems: mockEntities.length,
pageInfo: {},
};
},
};

// Dev-only placeholder so links generated by EntityRefLink (used for the
// group names in the calendar) resolve to a mounted route instead of
// throwing "No path for routeRef".
const EntityPagePlaceholder = () => <div>Entity page (dev placeholder)</div>;
attachComponentData(EntityPagePlaceholder, 'core.mountPoint', entityRouteRef);

createDevApp()
.registerPlugin(vacationCalendarPlugin)
.registerApi({
Expand Down Expand Up @@ -51,4 +69,8 @@ createDevApp()
title: 'Root Page',
path: '/vacation-calender',
})
.addPage({
path: '/catalog/:namespace/:kind/:name',
element: <EntityPagePlaceholder />,
})
.render();
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import Timeline, {
DateHeader,
} from 'react-calendar-timeline';
import dayjs from 'dayjs';
import { UserEntity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity, catalogApiRef } from '@backstage/plugin-catalog-react';
import {
useEntity,
catalogApiRef,
EntityRefLink,
} from '@backstage/plugin-catalog-react';
import {
Content,
ContentHeader,
SupportButton,
Link,
ErrorPanel,
Progress,
Avatar,
Expand Down Expand Up @@ -47,6 +51,8 @@ export const CalendarCard = () => {

const isUserEntity =
entity.kind.toLowerCase() === 'user' && entity.metadata.name;
const currentUserEmail =
(isUserEntity && (entity as UserEntity).spec?.profile?.email) || false;

const { value: users } = useAsync(async () => {
return isUserEntity
Expand All @@ -66,7 +72,11 @@ export const CalendarCard = () => {
const showLoader =
isAvailabilityLoading || isAvailabilityFetching || !isInitialized;

const [groups, groupIndexMap] = getGroups(availability, isUserEntity, users);
const [groups, groupIndexMap] = getGroups(
availability,
currentUserEmail,
users,
);
const scheduleItems = getScheduleItems(availability, groupIndexMap);

if (users?.length === 0) {
Expand Down Expand Up @@ -183,24 +193,26 @@ export const CalendarCard = () => {
groups={groups}
sidebarWidth={250}
groupRenderer={({ group }) => {
return (
<Link
to={`/catalog/default/user/${group.entity?.metadata.name}`}
>
<div className="custom-group">
<Avatar
displayName={
group.entity?.metadata.displayName as string
}
picture={group.entity?.spec.profile?.picture}
customStyles={{
width: 30,
height: 30,
}}
/>
<span className="title">{group.title}</span>
</div>
</Link>
const groupContent = (
<div className="custom-group">
<Avatar
displayName={group.entity?.metadata.displayName as string}
picture={group.entity?.spec.profile?.picture}
customStyles={{
width: 30,
height: 30,
}}
/>
<span className="title">{group.title}</span>
</div>
);

return group.entity ? (
<EntityRefLink entityRef={group.entity}>
{groupContent}
</EntityRefLink>
) : (
groupContent
);
}}
items={scheduleItems as any}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Entity, UserEntity } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/plugin-catalog-react';
import { fetchUserEntities } from './fetch';

const makeUser = (name: string): UserEntity => ({
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name },
spec: { profile: {}, memberOf: [] },
});

describe('fetchUserEntities', () => {
it('fetches every cursor page for a manager', async () => {
const queryEntities = jest
.fn()
.mockResolvedValueOnce({
items: [makeUser('alice')],
totalItems: 2,
pageInfo: { nextCursor: 'next-page' },
})
.mockResolvedValueOnce({
items: [makeUser('bob')],
totalItems: 2,
pageInfo: {},
});
const catalogApi = { queryEntities } as unknown as CatalogApi;
const manager: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'manager',
annotations: { manager: 'bossman' },
},
spec: {},
};

const users = await fetchUserEntities(catalogApi, manager);

expect(users.map(user => user.metadata.name)).toEqual(['alice', 'bob']);
expect(queryEntities).toHaveBeenNthCalledWith(1, {
filter: {
kind: 'User',
'metadata.annotations.manager': 'bossman',
},
limit: 500,
totalItems: 'exclude',
});
expect(queryEntities).toHaveBeenNthCalledWith(2, {
cursor: 'next-page',
limit: 500,
});
});
});
27 changes: 24 additions & 3 deletions plugins/vacation-calendar/src/components/CalendarCard/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ import {
import { CatalogApi } from '@backstage/plugin-catalog-react';

const MANAGER_ANNOTATION = 'manager';
const CATALOG_PAGE_SIZE = 500;

const fetchAllUserEntities = async (
catalogApi: CatalogApi,
filter: Record<string, string | string[]>,
) => {
const users: UserEntity[] = [];
let cursor: string | undefined;

do {
const response = await catalogApi.queryEntities(
cursor
? { cursor, limit: CATALOG_PAGE_SIZE }
: { filter, limit: CATALOG_PAGE_SIZE, totalItems: 'exclude' },
);

users.push(...(response.items as UserEntity[]));
cursor = response.pageInfo.nextCursor;
} while (cursor);

return users;
};

export const fetchUserEntities = async (
catalogApi: CatalogApi,
Expand All @@ -22,8 +44,7 @@ export const fetchUserEntities = async (
entity.metadata.annotations[MANAGER_ANNOTATION],
};

const { items } = await catalogApi.getEntities({ filter });
return items as UserEntity[];
return fetchAllUserEntities(catalogApi, filter);
};

export const fetchGroupEntities = async (
Expand All @@ -45,5 +66,5 @@ export const fetchGroupEntities = async (
],
};

return (await catalogApi.getEntities({ filter })).items as UserEntity[];
return fetchAllUserEntities(catalogApi, filter);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ const makeAvailability = (
pageParams: [0],
});

const makeUser = (name: string, displayName: string): UserEntityV1alpha1 => ({
const makeUser = (
name: string,
displayName: string,
email: string = `${name}@example.com`,
): UserEntityV1alpha1 => ({
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name },
spec: { profile: { displayName }, memberOf: [] },
spec: { profile: { displayName, email }, memberOf: [] },
});

describe('getGroups', () => {
Expand Down Expand Up @@ -93,6 +97,19 @@ describe('getGroups', () => {
expect(groups[1].title).toBe('unknown@example.com');
});

it('resolves the entity by email even when metadata.name differs from the email local part', () => {
const availability = makeAvailability([
{ scheduleId: 'Niklas.Aronsson@axis.com' },
]);
const users = [
makeUser('niklasar', 'Niklas Aronsson', 'Niklas.Aronsson@axis.com'),
];

const [groups] = getGroups(availability, false, users);

expect(groups[0].entity?.metadata.name).toBe('niklasar');
});

it('is case-insensitive when sorting', () => {
const availability = makeAvailability([
{ scheduleId: 'bob@example.com' },
Expand Down Expand Up @@ -120,7 +137,7 @@ describe('getGroups', () => {
]);
const users = [makeUser('alice', 'Alice'), makeUser('bob', 'Bob')];

const [groups] = getGroups(availability, 'alice', users);
const [groups] = getGroups(availability, 'alice@example.com', users);

const alice = groups.find(g => g.title === 'Alice');
const bob = groups.find(g => g.title === 'Bob');
Expand Down
Loading
Loading