diff --git a/.changeset/fix-vacation-calendar-user-links.md b/.changeset/fix-vacation-calendar-user-links.md new file mode 100644 index 00000000..4edcded7 --- /dev/null +++ b/.changeset/fix-vacation-calendar-user-links.md @@ -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. diff --git a/plugins/vacation-calendar/dev/__fixtures__/availability.json b/plugins/vacation-calendar/dev/__fixtures__/availability.json index e4bedfdb..c02dd575 100644 --- a/plugins/vacation-calendar/dev/__fixtures__/availability.json +++ b/plugins/vacation-calendar/dev/__fixtures__/availability.json @@ -2,7 +2,7 @@ { "availabilityView": "String", "error": { "@odata.type": "microsoft.graph.freeBusyError" }, - "scheduleId": "sophie", + "scheduleId": "sophie@mail.com", "scheduleItems": [ { "isPrivate": false, @@ -89,7 +89,7 @@ { "availabilityView": "String", "error": { "@odata.type": "microsoft.graph.freeBusyError" }, - "scheduleId": "rascal", + "scheduleId": "rascal@mail.com", "scheduleItems": [ { "isPrivate": false, @@ -158,7 +158,7 @@ { "availabilityView": "String", "error": { "@odata.type": "microsoft.graph.freeBusyError" }, - "scheduleId": "buster", + "scheduleId": "buster@mail.com", "scheduleItems": [ { "isPrivate": false, @@ -209,7 +209,7 @@ { "availabilityView": "String", "error": { "@odata.type": "microsoft.graph.freeBusyError" }, - "scheduleId": "jasmine", + "scheduleId": "jasmine@mail.com", "scheduleItems": [ { "isPrivate": false, diff --git a/plugins/vacation-calendar/dev/index.tsx b/plugins/vacation-calendar/dev/index.tsx index 75e3a8be..d88c1aa7 100644 --- a/plugins/vacation-calendar/dev/index.tsx +++ b/plugins/vacation-calendar/dev/index.tsx @@ -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'; @@ -18,8 +23,21 @@ const catalogApi: Partial = { async getEntities(): Promise { 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 = () =>
Entity page (dev placeholder)
; +attachComponentData(EntityPagePlaceholder, 'core.mountPoint', entityRouteRef); + createDevApp() .registerPlugin(vacationCalendarPlugin) .registerApi({ @@ -51,4 +69,8 @@ createDevApp() title: 'Root Page', path: '/vacation-calender', }) + .addPage({ + path: '/catalog/:namespace/:kind/:name', + element: , + }) .render(); diff --git a/plugins/vacation-calendar/src/components/CalendarCard/CalendarCard.tsx b/plugins/vacation-calendar/src/components/CalendarCard/CalendarCard.tsx index e3da7308..59fe4ed5 100644 --- a/plugins/vacation-calendar/src/components/CalendarCard/CalendarCard.tsx +++ b/plugins/vacation-calendar/src/components/CalendarCard/CalendarCard.tsx @@ -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, @@ -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 @@ -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) { @@ -183,24 +193,26 @@ export const CalendarCard = () => { groups={groups} sidebarWidth={250} groupRenderer={({ group }) => { - return ( - -
- - {group.title} -
- + const groupContent = ( +
+ + {group.title} +
+ ); + + return group.entity ? ( + + {groupContent} + + ) : ( + groupContent ); }} items={scheduleItems as any} diff --git a/plugins/vacation-calendar/src/components/CalendarCard/fetch.test.ts b/plugins/vacation-calendar/src/components/CalendarCard/fetch.test.ts new file mode 100644 index 00000000..1b8cdb04 --- /dev/null +++ b/plugins/vacation-calendar/src/components/CalendarCard/fetch.test.ts @@ -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, + }); + }); +}); diff --git a/plugins/vacation-calendar/src/components/CalendarCard/fetch.ts b/plugins/vacation-calendar/src/components/CalendarCard/fetch.ts index 57cbd085..7ef307e1 100644 --- a/plugins/vacation-calendar/src/components/CalendarCard/fetch.ts +++ b/plugins/vacation-calendar/src/components/CalendarCard/fetch.ts @@ -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, +) => { + 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, @@ -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 ( @@ -45,5 +66,5 @@ export const fetchGroupEntities = async ( ], }; - return (await catalogApi.getEntities({ filter })).items as UserEntity[]; + return fetchAllUserEntities(catalogApi, filter); }; diff --git a/plugins/vacation-calendar/src/components/CalendarCard/lib.test.ts b/plugins/vacation-calendar/src/components/CalendarCard/lib.test.ts index ab75145f..e8eaab55 100644 --- a/plugins/vacation-calendar/src/components/CalendarCard/lib.test.ts +++ b/plugins/vacation-calendar/src/components/CalendarCard/lib.test.ts @@ -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', () => { @@ -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' }, @@ -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'); diff --git a/plugins/vacation-calendar/src/components/CalendarCard/lib.ts b/plugins/vacation-calendar/src/components/CalendarCard/lib.ts index 6e3e1397..496528e2 100644 --- a/plugins/vacation-calendar/src/components/CalendarCard/lib.ts +++ b/plugins/vacation-calendar/src/components/CalendarCard/lib.ts @@ -10,18 +10,26 @@ const isTimeLineItem = ( return item !== undefined; }; -const getFullName = (users: UserEntity[], userId: string) => { - const [name] = userId.split('@'); - const user = users.find(u => u.metadata.name === name); +const createUsersByEmail = (users: UserEntity[]) => { + return users.reduce((usersByEmail, user) => { + const email = user.spec.profile?.email; - if (user) return user.spec.profile?.displayName; - return userId; + if (email) { + usersByEmail.set(email.toLowerCase(), user); + } + + return usersByEmail; + }, new Map()); +}; + +const getUser = (usersByEmail: Map, userId: string) => { + return usersByEmail.get(userId.toLowerCase()); }; -const getUser = (users: UserEntity[], userId: string) => { - const [name] = userId.split('@'); +const getFullName = (usersByEmail: Map, userId: string) => { + const user = getUser(usersByEmail, userId); - return users.find(u => u.metadata.name === name); + return user?.spec.profile?.displayName ?? userId; }; export const getScheduleItems = ( @@ -53,7 +61,7 @@ export const getScheduleItems = ( export const getGroups = ( availability: InfiniteData | undefined, - isUserEntity: string | false, + currentUserEmail: string | false, users: UserEntityV1alpha1[] | undefined, ): [ groups: Array<{ @@ -69,12 +77,13 @@ export const getGroups = ( return [[], new Map()]; } + const usersByEmail = createUsersByEmail(users || []); const flattenedSchedules = availability.pages .flatMap(p => p) .map((a, originalIndex) => ({ originalIndex, schedule: a, - title: getFullName(users || [], a.scheduleId || ''), + title: getFullName(usersByEmail, a.scheduleId || ''), })); // Sort by title (display name) alphabetically @@ -91,15 +100,15 @@ export const getGroups = ( const groups = sortedSchedules.map((item, newIndex) => { const isHighlighted = - isUserEntity && - item.schedule.scheduleId && - isUserEntity === item.schedule.scheduleId?.split('@')[0]; + currentUserEmail && + item.schedule.scheduleId?.toLowerCase() === + currentUserEmail.toLowerCase(); return { id: newIndex, highlight: !!isHighlighted, title: item.title || '', - entity: getUser(users || [], item.schedule.scheduleId || ''), + entity: getUser(usersByEmail, item.schedule.scheduleId || ''), height: 30, }; });