Merge pull request #26864 from deejay1/feat/26838

feat(user-settings): Search for user avatar also in the entities
This commit is contained in:
Fredrik Adelöw
2024-10-08 12:07:46 +02:00
committed by GitHub
11 changed files with 213 additions and 33 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-user-settings': patch
---
`useUserProfile` will now use the user's picture stored in the catalog as a fallback if the identity provider doesn't return a picture.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
Add missing doc string to API
+1
View File
@@ -650,6 +650,7 @@ export interface CatalogApi {
*
* @param entity - Entity to validate
* @param locationRef - Location ref in format `url:http://example.com/file`
* @param options - Additional options
*/
validateEntity(
entity: Entity,
+28 -1
View File
@@ -17,10 +17,37 @@
import React from 'react';
import { CatalogEntityPage } from '@backstage/plugin-catalog';
import { createDevApp } from '@backstage/dev-utils';
import { userSettingsPlugin, UserSettingsPage } from '../src/plugin';
import { UserSettingsPage, userSettingsPlugin } from '../src';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import {
CompoundEntityRef,
Entity,
UserEntity,
} from '@backstage/catalog-model';
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'Guest',
},
spec: {},
};
createDevApp()
.registerPlugin(userSettingsPlugin)
.registerApi({
api: catalogApiRef,
deps: {},
factory() {
return {
async getEntityByRef(
_: string | CompoundEntityRef,
): Promise<Entity | undefined> {
return userEntity;
},
} as Partial<CatalogApi> as unknown as CatalogApi;
},
})
.addPage({
title: 'Settings',
path: '/settings',
+1
View File
@@ -55,6 +55,7 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
@@ -15,24 +15,33 @@
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { DefaultSettingsPage } from './DefaultSettingsPage';
import { UserSettingsTab } from '../UserSettingsTab';
import { useOutlet } from 'react-router-dom';
import { SettingsLayout } from '../SettingsLayout';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useOutlet: jest.fn().mockReturnValue(undefined),
}));
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
describe('<DefaultSettingsPage />', () => {
beforeEach(() => {
(useOutlet as jest.Mock).mockReset();
});
it('should render the settings page with 3 tabs', async () => {
const { container } = await renderInTestApp(<DefaultSettingsPage />);
const { container } = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<DefaultSettingsPage />
</TestApiProvider>,
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
expect(tabs).toHaveLength(3);
@@ -45,7 +54,9 @@ describe('<DefaultSettingsPage />', () => {
</UserSettingsTab>
);
const { container } = await renderInTestApp(
<DefaultSettingsPage tabs={[advancedTabRoute]} />,
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<DefaultSettingsPage tabs={[advancedTabRoute]} />
</TestApiProvider>,
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
@@ -60,7 +71,9 @@ describe('<DefaultSettingsPage />', () => {
</SettingsLayout.Route>
);
const { container } = await renderInTestApp(
<DefaultSettingsPage tabs={[advancedTabRoute]} />,
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<DefaultSettingsPage tabs={[advancedTabRoute]} />
</TestApiProvider>,
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
@@ -20,19 +20,27 @@ import React from 'react';
import { UserSettingsIdentityCard } from './UserSettingsIdentityCard';
import { ApiProvider } from '@backstage/core-app-api';
import { identityApiRef } from '@backstage/core-plugin-api';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
const apiRegistry = TestApiRegistry.from([
identityApiRef,
{
getProfileInfo: jest.fn(async () => ({})),
getBackstageIdentity: jest.fn(async () => ({
type: 'user' as const,
userEntityRef: 'foo:bar/foobar',
ownershipEntityRefs: ['user:default/test-ownership'],
})),
},
]);
const apiRegistry = TestApiRegistry.from(
[
identityApiRef,
{
getProfileInfo: jest.fn(async () => ({})),
getBackstageIdentity: jest.fn(async () => ({
type: 'user' as const,
userEntityRef: 'foo:bar/foobar',
ownershipEntityRefs: ['user:default/test-ownership'],
})),
},
],
[
catalogApiRef,
{
getEntityByRef: jest.fn(),
},
],
);
describe('<UserSettingsIdentityCard />', () => {
it('displays an identity card', async () => {
@@ -0,0 +1,74 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { identityApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
import { ApiProvider } from '@backstage/core-app-api';
import { UserSettingsProfileCard } from './UserSettingsProfileCard';
const apiRegistry = TestApiRegistry.from(
[
identityApiRef,
{
getProfileInfo: jest.fn(async () => ({})),
getBackstageIdentity: jest.fn(async () => ({
type: 'user' as const,
userEntityRef: 'foo:bar/foobar',
ownershipEntityRefs: ['user:default/test-ownership'],
})),
},
],
[
catalogApiRef,
{
getEntityByRef: jest.fn(async () => {
return {
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'Guest',
annotations: {},
},
spec: {
profile: {
picture: 'https://example.com/avatar.png',
},
},
};
}),
},
],
);
describe('<UserSettingsProfileCard />', () => {
it('displays avatar if it exists in user entity', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<UserSettingsProfileCard />
</ApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);
expect(screen.getByAltText('Profile picture')).toHaveAttribute(
'src',
'https://example.com/avatar.png',
);
});
});
@@ -15,29 +15,42 @@
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { SettingsPage } from './SettingsPage';
import { UserSettingsTab } from '../UserSettingsTab';
import { useOutlet } from 'react-router-dom';
import { SettingsLayout } from '../SettingsLayout';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useOutlet: jest.fn().mockReturnValue(undefined),
}));
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
describe('<SettingsPage />', () => {
beforeEach(() => {
(useOutlet as jest.Mock).mockReset();
});
it('should render the default settings page with 3 tabs', async () => {
const { container } = await renderInTestApp(<SettingsPage />, {
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
});
const { container } = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<SettingsPage />
</TestApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
expect(tabs).toHaveLength(3);
@@ -50,9 +63,14 @@ describe('<SettingsPage />', () => {
</UserSettingsTab>
);
(useOutlet as jest.Mock).mockReturnValue(advancedTabRoute);
const { container } = await renderInTestApp(<SettingsPage />, {
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
});
const { container } = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<SettingsPage />
</TestApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
expect(tabs).toHaveLength(4);
@@ -66,9 +84,14 @@ describe('<SettingsPage />', () => {
</SettingsLayout.Route>
);
(useOutlet as jest.Mock).mockReturnValue(advancedTabRoute);
const { container } = await renderInTestApp(<SettingsPage />, {
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
});
const { container } = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<SettingsPage />
</TestApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
expect(tabs).toHaveLength(4);
@@ -91,9 +114,14 @@ describe('<SettingsPage />', () => {
</SettingsLayout>
);
(useOutlet as jest.Mock).mockReturnValue(customLayout);
const { container } = await renderInTestApp(<SettingsPage />, {
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
});
const { container } = await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<SettingsPage />
</TestApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);
const tabs = container.querySelectorAll('[class*=MuiTabs-root] a');
expect(tabs).toHaveLength(2);
@@ -22,16 +22,33 @@ import {
} from '@backstage/core-plugin-api';
import { useEffect } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { UserEntity } from '@backstage/catalog-model';
/** @public */
export const useUserProfile = () => {
const identityApi = useApi(identityApiRef);
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const { value, loading, error } = useAsync(async () => {
let identityProfile = await identityApi.getProfileInfo();
const backStageIdentity = await identityApi.getBackstageIdentity();
const catalogProfile = (await catalogApi.getEntityByRef(
backStageIdentity.userEntityRef,
)) as unknown as UserEntity;
if (
identityProfile.picture === undefined &&
catalogProfile?.spec?.profile?.picture
) {
identityProfile = {
...identityProfile,
picture: catalogProfile.spec.profile.picture,
};
}
return {
profile: await identityApi.getProfileInfo(),
identity: await identityApi.getBackstageIdentity(),
profile: identityProfile,
identity: backStageIdentity,
};
}, []);
+1
View File
@@ -8438,6 +8438,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-user-settings@workspace:plugins/user-settings"
dependencies:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-compat-api": "workspace:^"