Merge remote-tracking branch 'origin/master' into update-jest-docs
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Location rule target patterns now also match hidden files, i.e. path components with a leading dot.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-entity-feedback-backend': minor
|
||||
'@backstage/plugin-entity-feedback-common': minor
|
||||
'@backstage/plugin-entity-feedback': minor
|
||||
---
|
||||
|
||||
Implement entity feedback plugin, check out the `README.md` for more details!
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Fixed an issue where entities sometimes were not properly deleted during a full mutation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Provide better error messaging when GitHub fails due to missing team definitions
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
'@backstage/app-defaults': minor
|
||||
'@backstage/core-app-api': minor
|
||||
'@backstage/plugin-user-settings': minor
|
||||
'@backstage/plugin-auth-backend': minor
|
||||
---
|
||||
|
||||
Added a Bitbucket Server Auth Provider and added its API to the app defaults
|
||||
@@ -37,6 +37,8 @@ yarn.lock @backstage/maintainers @back
|
||||
/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva
|
||||
/plugins/cost-insights @backstage/maintainers @backstage/silver-lining
|
||||
/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining
|
||||
/plugins/entity-feedback @backstage/maintainers @kuangp
|
||||
/plugins/entity-feedback-* @backstage/maintainers @kuangp
|
||||
/plugins/events-backend @backstage/maintainers @pjungermann
|
||||
/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann
|
||||
/plugins/events-backend-module-azure @backstage/maintainers @pjungermann
|
||||
|
||||
@@ -61,7 +61,7 @@ how this is done. Note that for the Bitbucket provider, you'll want to use
|
||||
factory.
|
||||
|
||||
The `@backstage/plugin-auth-backend` plugin also comes with two built-in
|
||||
resolves that can be used if desired. The first one is the
|
||||
resolvers that can be used if desired. The first one is the
|
||||
`bitbucketUsernameSignInResolver`, which identifies users by matching their
|
||||
Bitbucket username to `bitbucket.org/username` annotations of `User` entities in
|
||||
the catalog. Note that you must populate your catalog with matching entities or
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: provider
|
||||
title: Bitbucket Server Authentication Provider
|
||||
sidebar_label: Bitbucket Server
|
||||
description: Adding Bitbucket Server OAuth as an authentication provider in Backstage
|
||||
---
|
||||
|
||||
The Backstage `core-plugin-api` package comes with a Bitbucket Server authentication provider that can authenticate
|
||||
users using Bitbucket Server. This does **NOT** work with Bitbucket Cloud.
|
||||
|
||||
## Create an Application Link in Bitbucket Server
|
||||
|
||||
To add Bitbucket Server authentication, you must create an outgoing application link. Follow the steps described in
|
||||
the [Bitbucket Server documentation](https://confluence.atlassian.com/bitbucketserver/configure-an-outgoing-link-1108483656.html)
|
||||
to create one.
|
||||
|
||||
## Configuration
|
||||
|
||||
The provider configuration can then be added to your `app-config.yaml` under the root `auth` configuration:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
environment: development
|
||||
providers:
|
||||
bitbucketServer:
|
||||
development:
|
||||
host: bitbucket.org
|
||||
clientId: ${AUTH_BITBUCKET_SERVER_CLIENT_ID}
|
||||
clientSecret: ${AUTH_BITBUCKET_SERVER_CLIENT_SECRET}
|
||||
```
|
||||
|
||||
The Bitbucket Server provider is a structure with two configuration keys:
|
||||
|
||||
- `clientId`: The client ID that was generated by Bitbucket, e.g. `b0f868455c15dcdff5c5fb5d173ae684`.
|
||||
- `clientSecret`: The client secret tied to the generated client ID.
|
||||
|
||||
## Adding the provider to the Backstage frontend
|
||||
|
||||
To add the provider to the frontend, add the `bitbucketServerAuthApi` reference and `SignInPage` component as shown
|
||||
in [Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page).
|
||||
|
||||
## Using Bitbucket Server for sign-in
|
||||
|
||||
In order to use the Bitbucket Server provider for sign-in, you must configure it with a `signIn.resolver`. See
|
||||
the [Sign-In Resolver documentation](../identity-resolver.md) for more details on how this is done. Note that for the
|
||||
Bitbucket Server provider, you'll want to use `bitbucketServer` as the provider ID,
|
||||
and `providers.bitbucketServer.create` for the provider factory.
|
||||
|
||||
The `@backstage/plugin-auth-backend` plugin also comes with a built-in resolver that can be used if desired.
|
||||
The `emailMatchingUserEntityProfileEmail` identifies users by matching their Bitbucket Server email address to the email
|
||||
address of `User` entities in the catalog. Note that you must populate your catalog with matching entities or users will
|
||||
not be able to sign in with this resolver.
|
||||
@@ -18,6 +18,7 @@ Backstage comes with many common authentication providers in the core library:
|
||||
- [Auth0](auth0/provider.md)
|
||||
- [Azure](microsoft/provider.md)
|
||||
- [Bitbucket](bitbucket/provider.md)
|
||||
- [Bitbucket Server](bitbucketServer/provider.md)
|
||||
- [Cloudflare Access](cloudflare/access.md)
|
||||
- [GitHub](github/provider.md)
|
||||
- [GitLab](gitlab/provider.md)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Entity Feedback
|
||||
author: Phil Kuang
|
||||
authorUrl: https://github.com/kuangp
|
||||
category: Quality
|
||||
description: Give and view feedback on entities available in the Backstage catalog.
|
||||
documentation: https://github.com/backstage/backstage/tree/master/plugins/entity-feedback
|
||||
iconUrl: img/entity-feedback-logo.png
|
||||
npmPackageName: '@backstage/plugin-entity-feedback'
|
||||
addedDate: '2023-01-20'
|
||||
|
After Width: | Height: | Size: 894 B |
@@ -25,6 +25,7 @@ import {
|
||||
GitlabAuth,
|
||||
MicrosoftAuth,
|
||||
BitbucketAuth,
|
||||
BitbucketServerAuth,
|
||||
OAuthRequestManager,
|
||||
WebStorage,
|
||||
UrlPatternDiscovery,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
configApiRef,
|
||||
oneloginAuthApiRef,
|
||||
bitbucketAuthApiRef,
|
||||
bitbucketServerAuthApiRef,
|
||||
atlassianAuthApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
@@ -219,6 +221,19 @@ export const apis = [
|
||||
environment: configApi.getOptionalString('auth.environment'),
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: bitbucketServerAuthApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi }) =>
|
||||
BitbucketServerAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
defaultScopes: ['REPO_READ'],
|
||||
}),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: atlassianAuthApiRef,
|
||||
deps: {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@backstage/plugin-code-coverage": "workspace:^",
|
||||
"@backstage/plugin-cost-insights": "workspace:^",
|
||||
"@backstage/plugin-dynatrace": "workspace:^",
|
||||
"@backstage/plugin-entity-feedback": "workspace:^",
|
||||
"@backstage/plugin-explore": "workspace:^",
|
||||
"@backstage/plugin-gcalendar": "workspace:^",
|
||||
"@backstage/plugin-gcp-projects": "workspace:^",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
RELATION_PART_OF,
|
||||
RELATION_PROVIDES_API,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EmptyState } from '@backstage/core-components';
|
||||
import { EmptyState, InfoCard } from '@backstage/core-components';
|
||||
import {
|
||||
EntityApiDefinitionCard,
|
||||
EntityConsumedApisCard,
|
||||
@@ -79,6 +79,11 @@ import {
|
||||
DynatraceTab,
|
||||
isDynatraceAvailable,
|
||||
} from '@backstage/plugin-dynatrace';
|
||||
import {
|
||||
EntityFeedbackResponseContent,
|
||||
EntityLikeDislikeRatingsCard,
|
||||
LikeDislikeButtons,
|
||||
} from '@backstage/plugin-entity-feedback';
|
||||
import {
|
||||
EntityGithubActionsContent,
|
||||
EntityRecentGithubActionsRunsCard,
|
||||
@@ -376,6 +381,12 @@ const overviewContent = (
|
||||
</EntitySwitch.Case>
|
||||
</EntitySwitch>
|
||||
|
||||
<Grid item md={2}>
|
||||
<InfoCard title="Rate this entity">
|
||||
<LikeDislikeButtons />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
|
||||
{cicdCard}
|
||||
|
||||
<EntitySwitch>
|
||||
@@ -513,6 +524,10 @@ const serviceEntityPage = (
|
||||
>
|
||||
<DynatraceTab />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
@@ -592,6 +607,10 @@ const websiteEntityPage = (
|
||||
<EntityLayout.Route path="/todos" title="TODOs">
|
||||
<EntityTodoContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
@@ -608,6 +627,10 @@ const defaultEntityPage = (
|
||||
<EntityLayout.Route path="/todos" title="TODOs">
|
||||
<EntityTodoContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
@@ -644,6 +667,11 @@ const apiPage = (
|
||||
<Grid item xs={12} md={6}>
|
||||
<EntityConsumingComponentsCard />
|
||||
</Grid>
|
||||
<Grid item md={2}>
|
||||
<InfoCard title="Rate this entity">
|
||||
<LikeDislikeButtons />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -656,6 +684,10 @@ const apiPage = (
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
@@ -673,6 +705,9 @@ const userPage = (
|
||||
entityFilterKind={customEntityFilterKind}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<EntityLikeDislikeRatingsCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
@@ -695,6 +730,9 @@ const groupPage = (
|
||||
<Grid item xs={12}>
|
||||
<EntityMembersListCard />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<EntityLikeDislikeRatingsCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
@@ -720,6 +758,11 @@ const systemPage = (
|
||||
<Grid item md={6}>
|
||||
<EntityHasResourcesCard variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={2}>
|
||||
<InfoCard title="Rate this entity">
|
||||
<LikeDislikeButtons />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route path="/score" title="Score">
|
||||
@@ -748,6 +791,9 @@ const systemPage = (
|
||||
unidirectional={false}
|
||||
/>
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
@@ -765,8 +811,16 @@ const domainPage = (
|
||||
<Grid item md={6}>
|
||||
<EntityHasSystemsCard variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={2}>
|
||||
<InfoCard title="Rate this entity">
|
||||
<LikeDislikeButtons />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route path="/feedback" title="Feedback">
|
||||
<EntityFeedbackResponseContent />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
microsoftAuthApiRef,
|
||||
oneloginAuthApiRef,
|
||||
bitbucketAuthApiRef,
|
||||
bitbucketServerAuthApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const providers = [
|
||||
@@ -67,4 +68,10 @@ export const providers = [
|
||||
message: 'Sign In using Bitbucket',
|
||||
apiRef: bitbucketAuthApiRef,
|
||||
},
|
||||
{
|
||||
id: 'bitbucket-server-auth-provider',
|
||||
title: 'Bitbucket Server',
|
||||
message: 'Sign In using Bitbucket Server',
|
||||
apiRef: bitbucketServerAuthApiRef,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@backstage/plugin-catalog-backend": "workspace:^",
|
||||
"@backstage/plugin-catalog-node": "workspace:^",
|
||||
"@backstage/plugin-code-coverage-backend": "workspace:^",
|
||||
"@backstage/plugin-entity-feedback-backend": "workspace:^",
|
||||
"@backstage/plugin-events-backend": "workspace:^",
|
||||
"@backstage/plugin-events-node": "workspace:^",
|
||||
"@backstage/plugin-explore-backend": "workspace:^",
|
||||
|
||||
@@ -44,6 +44,7 @@ import azureDevOps from './plugins/azure-devops';
|
||||
import catalog from './plugins/catalog';
|
||||
import catalogEventBasedProviders from './plugins/catalogEventBasedProviders';
|
||||
import codeCoverage from './plugins/codecoverage';
|
||||
import entityFeedback from './plugins/entityFeedback';
|
||||
import events from './plugins/events';
|
||||
import explore from './plugins/explore';
|
||||
import kubernetes from './plugins/kubernetes';
|
||||
@@ -147,6 +148,9 @@ async function main() {
|
||||
);
|
||||
const permissionEnv = useHotMemoize(module, () => createEnv('permission'));
|
||||
const playlistEnv = useHotMemoize(module, () => createEnv('playlist'));
|
||||
const entityFeedbackEnv = useHotMemoize(module, () =>
|
||||
createEnv('entityFeedback'),
|
||||
);
|
||||
const eventsEnv = useHotMemoize(module, () => createEnv('events'));
|
||||
const exploreEnv = useHotMemoize(module, () => createEnv('explore'));
|
||||
const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse'));
|
||||
@@ -179,6 +183,7 @@ async function main() {
|
||||
apiRouter.use('/permission', await permission(permissionEnv));
|
||||
apiRouter.use('/playlist', await playlist(playlistEnv));
|
||||
apiRouter.use('/explore', await explore(exploreEnv));
|
||||
apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv));
|
||||
apiRouter.use('/adr', await adr(adrEnv));
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@ export default async function createPlugin(
|
||||
},
|
||||
}),
|
||||
|
||||
bitbucketServer: providers.bitbucketServer.create({
|
||||
signIn: {
|
||||
resolver:
|
||||
providers.bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
|
||||
},
|
||||
}),
|
||||
|
||||
// This is an example of how to configure the OAuth2Proxy provider as well
|
||||
// as how to sign a user in without a matching user entity in the catalog.
|
||||
// You can try it out using `<ProxiedSignInPage {...props} provider="myproxy" />`
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createRouter } from '@backstage/plugin-entity-feedback-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default function createPlugin(env: PluginEnvironment): Promise<Router> {
|
||||
return createRouter({
|
||||
database: env.database,
|
||||
discovery: env.discovery,
|
||||
identity: env.identity,
|
||||
logger: env.logger,
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api';
|
||||
import { BackstageIdentityResponse } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
@@ -280,6 +281,25 @@ export class BitbucketAuth {
|
||||
static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class BitbucketServerAuth {
|
||||
// (undocumented)
|
||||
static create(
|
||||
options: OAuthApiCreateOptions,
|
||||
): typeof bitbucketServerAuthApiRef.T;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type BitbucketServerSession = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type BitbucketSession = {
|
||||
providerInfo: {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2020 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
|
||||
import { UrlPatternDiscovery } from '../../DiscoveryApi';
|
||||
import BitbucketServerAuth from './BitbucketServerAuth';
|
||||
|
||||
const getSession = jest.fn();
|
||||
|
||||
jest.mock('../../../../lib/AuthSessionManager', () => ({
|
||||
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
|
||||
RefreshingAuthSessionManager: class {
|
||||
getSession = getSession;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BitbucketServerAuth', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['PUBLIC_REPOS', ['PUBLIC_REPOS']],
|
||||
['PROJECT_ADMIN REPO_READ', ['PROJECT_ADMIN', 'REPO_READ']],
|
||||
[
|
||||
'PROJECT_ADMIN REPO_READ ACCOUNT_WRITE',
|
||||
['PROJECT_ADMIN', 'REPO_READ', 'ACCOUNT_WRITE'],
|
||||
],
|
||||
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
|
||||
const bitbucketServerAuth = BitbucketServerAuth.create({
|
||||
oauthRequestApi: new MockOAuthApi(),
|
||||
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
|
||||
});
|
||||
|
||||
bitbucketServerAuth.getAccessToken(scope);
|
||||
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2020 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 {
|
||||
BackstageIdentityResponse,
|
||||
bitbucketServerAuthApiRef,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
import { OAuth2 } from '../oauth2';
|
||||
|
||||
export type BitbucketServerAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'bitbucketServer',
|
||||
title: 'Bitbucket Server',
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements the OAuth flow to Bitbucket Server.
|
||||
* @public
|
||||
*/
|
||||
export default class BitbucketServerAuth {
|
||||
static create(
|
||||
options: OAuthApiCreateOptions,
|
||||
): typeof bitbucketServerAuthApiRef.T {
|
||||
const {
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = ['PROJECT_ADMIN'],
|
||||
} = options;
|
||||
|
||||
return OAuth2.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
provider,
|
||||
environment,
|
||||
defaultScopes,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 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.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { default as BitbucketServerAuth } from './BitbucketServerAuth';
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2020 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 {
|
||||
ProfileInfo,
|
||||
BackstageIdentityResponse,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* Session information for Bitbucket Server auth.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BitbucketServerSession = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentityResponse;
|
||||
};
|
||||
@@ -23,5 +23,6 @@ export * from './saml';
|
||||
export * from './microsoft';
|
||||
export * from './onelogin';
|
||||
export * from './bitbucket';
|
||||
export * from './bitbucketServer';
|
||||
export * from './atlassian';
|
||||
export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types';
|
||||
|
||||
@@ -241,6 +241,11 @@ export const bitbucketAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const bitbucketServerAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config' | 'load-chunk';
|
||||
|
||||
@@ -412,6 +412,21 @@ export const bitbucketAuthApiRef: ApiRef<
|
||||
id: 'core.auth.bitbucket',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Bitbucket Server APIs.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes}
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const bitbucketServerAuthApiRef: ApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.bitbucket-server',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication towards Atlassian APIs.
|
||||
*
|
||||
|
||||
@@ -132,6 +132,19 @@ export type BitbucketPassportProfile = Profile & {
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type BitbucketServerOAuthResult = {
|
||||
fullProfile: Profile;
|
||||
params: {
|
||||
scope: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class CatalogIdentityClient {
|
||||
constructor(options: { catalogApi: CatalogApi; tokenManager: TokenManager });
|
||||
@@ -480,6 +493,23 @@ export const providers: Readonly<{
|
||||
userIdMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
|
||||
}>;
|
||||
}>;
|
||||
bitbucketServer: Readonly<{
|
||||
create: (
|
||||
options?:
|
||||
| {
|
||||
authHandler?: AuthHandler<BitbucketServerOAuthResult> | undefined;
|
||||
signIn?:
|
||||
| {
|
||||
resolver: SignInResolver<BitbucketServerOAuthResult>;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
resolvers: Readonly<{
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<BitbucketServerOAuthResult>;
|
||||
}>;
|
||||
}>;
|
||||
cfAccess: Readonly<{
|
||||
create: (options: {
|
||||
authHandler?: AuthHandler<CloudflareAccessResult> | undefined;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 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.
|
||||
*/
|
||||
|
||||
export { bitbucketServer } from './provider';
|
||||
export type { BitbucketServerOAuthResult } from './provider';
|
||||
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
* Copyright 2020 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 * as helpers from '../../lib/passport/PassportStrategyHelper';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import {
|
||||
bitbucketServer,
|
||||
BitbucketServerAuthProvider,
|
||||
BitbucketServerOAuthResult,
|
||||
} from './provider';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
|
||||
jest.mock('../../lib/passport/PassportStrategyHelper', () => {
|
||||
return {
|
||||
...jest.requireActual('../../lib/passport/PassportStrategyHelper'),
|
||||
executeFrameHandlerStrategy: jest.fn(),
|
||||
executeRefreshTokenStrategy: jest.fn(),
|
||||
executeFetchUserProfileStrategy: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFrameHandler = jest.spyOn(
|
||||
helpers,
|
||||
'executeFrameHandlerStrategy',
|
||||
) as unknown as jest.MockedFunction<
|
||||
() => Promise<{
|
||||
result: BitbucketServerOAuthResult;
|
||||
privateInfo: { refreshToken?: string };
|
||||
}>
|
||||
>;
|
||||
|
||||
const passportProfile = {
|
||||
id: '123',
|
||||
username: 'john.doe',
|
||||
provider: 'bitubcketServer',
|
||||
displayName: 'John Doe',
|
||||
emails: [{ value: 'john@doe.com' }],
|
||||
photos: [{ value: 'https://bitbucket.org/user/123/avatar' }],
|
||||
};
|
||||
|
||||
const mockHost = 'bitbucket.org';
|
||||
const mockBaseUrl = `https://${mockHost}`;
|
||||
|
||||
const whoAmIHandler = (options?: { fail?: boolean; value?: string }) =>
|
||||
rest.get(
|
||||
`${mockBaseUrl}/plugins/servlet/applinks/whoami`,
|
||||
(_req, res, ctx) => {
|
||||
if (options?.fail) {
|
||||
res.networkError('error');
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('X-Ausername', options?.value ?? passportProfile.username),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const getUserHandler = (options?: {
|
||||
fail?: boolean;
|
||||
status?: number;
|
||||
avatarUrl?: string;
|
||||
noDisplayName?: boolean;
|
||||
noUserName?: boolean;
|
||||
}) =>
|
||||
rest.get(
|
||||
`${mockBaseUrl}/rest/api/latest/users/${passportProfile.username}`,
|
||||
(_req, res, ctx) => {
|
||||
if (options?.fail) {
|
||||
res.networkError('error');
|
||||
}
|
||||
return res(
|
||||
ctx.status(options?.status ?? 200),
|
||||
ctx.json({
|
||||
name: options?.noUserName ? undefined : 'john.doe',
|
||||
emailAddress: 'john@doe.com',
|
||||
id: 123,
|
||||
displayName: options?.noDisplayName ? undefined : 'John Doe',
|
||||
active: true,
|
||||
slug: 'john.doe',
|
||||
type: 'NORMAL',
|
||||
links: {
|
||||
self: [
|
||||
{
|
||||
href: 'https://bitbucket.org/users/john.doe',
|
||||
},
|
||||
],
|
||||
},
|
||||
avatarUrl: options?.avatarUrl ?? '/user/123/avatar',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe('BitbucketServerAuthProvider', () => {
|
||||
const provider = new BitbucketServerAuthProvider({
|
||||
resolverContext: {
|
||||
signInWithCatalogUser: jest.fn(info => {
|
||||
return {
|
||||
token: `token-for-user:${info.filter['spec.profile.email']}`,
|
||||
};
|
||||
}),
|
||||
} as unknown as AuthResolverContext,
|
||||
signInResolver:
|
||||
bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
callbackUrl: 'mock',
|
||||
clientId: 'mock',
|
||||
clientSecret: 'mock',
|
||||
host: mockHost,
|
||||
authorizationUrl: 'mock',
|
||||
tokenUrl: 'mock',
|
||||
});
|
||||
|
||||
describe('when transforming to type OAuthResponse', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
it('should map to a valid response', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler());
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
|
||||
const expected = {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: 'John Doe',
|
||||
picture: 'https://bitbucket.org/user/123/avatar',
|
||||
},
|
||||
};
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should throw if whoami fails', async () => {
|
||||
server.use(whoAmIHandler({ fail: true }), getUserHandler());
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
|
||||
await expect(provider.handler({} as any)).rejects.toThrow(
|
||||
`Failed to retrieve the username of the logged in user`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if whoami returns an invalid response', async () => {
|
||||
server.use(whoAmIHandler({ value: '' }), getUserHandler());
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
|
||||
await expect(provider.handler({} as any)).rejects.toThrow(
|
||||
`Failed to retrieve the username of the logged in user`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if get user fails', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler({ fail: true }));
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
|
||||
await expect(provider.handler({} as any)).rejects.toThrow(
|
||||
`Failed to retrieve the user '${passportProfile.username}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if get user is not ok', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler({ status: 500 }));
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
|
||||
await expect(provider.handler({} as any)).rejects.toThrow(
|
||||
`Failed to retrieve the user '${passportProfile.username}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set an avatar url if not given', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler({ avatarUrl: '' }));
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
|
||||
const expected = {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: 'John Doe',
|
||||
},
|
||||
};
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should fallback to the username if no displayName is given', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler({ noDisplayName: true }));
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
|
||||
const expected = {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: 'john.doe',
|
||||
picture: 'https://bitbucket.org/user/123/avatar',
|
||||
},
|
||||
};
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should fallback to the user id if no name is given', async () => {
|
||||
server.use(
|
||||
whoAmIHandler(),
|
||||
getUserHandler({ noDisplayName: true, noUserName: true }),
|
||||
);
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
|
||||
const expected = {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: '123',
|
||||
picture: 'https://bitbucket.org/user/123/avatar',
|
||||
},
|
||||
};
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when authenticating', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
it('should forward the refresh token', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler());
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: { refreshToken: 'refresh-token' },
|
||||
});
|
||||
|
||||
const response = await provider.handler({} as any);
|
||||
|
||||
const expected = {
|
||||
response: {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: 'John Doe',
|
||||
picture: 'https://bitbucket.org/user/123/avatar',
|
||||
},
|
||||
},
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should forward a new refresh token on refresh', async () => {
|
||||
server.use(whoAmIHandler(), getUserHandler());
|
||||
|
||||
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
|
||||
const params = { scope: 'REPO_READ' };
|
||||
const mockRefreshToken = jest.spyOn(
|
||||
helpers,
|
||||
'executeRefreshTokenStrategy',
|
||||
) as unknown as jest.MockedFunction<() => Promise<{}>>;
|
||||
mockRefreshToken.mockResolvedValueOnce({
|
||||
accessToken,
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
params,
|
||||
});
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile: passportProfile, accessToken, params },
|
||||
privateInfo: { refreshToken: 'refresh-token' },
|
||||
});
|
||||
|
||||
const expected = {
|
||||
response: {
|
||||
backstageIdentity: {
|
||||
token: 'token-for-user:john@doe.com',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: '19xasczxcm9n7gacn9jdgm19me',
|
||||
scope: 'REPO_READ',
|
||||
},
|
||||
profile: {
|
||||
email: 'john@doe.com',
|
||||
displayName: 'John Doe',
|
||||
picture: 'https://bitbucket.org/user/123/avatar',
|
||||
},
|
||||
},
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
};
|
||||
const response = await provider.refresh({ scope: 'REPO_WRITE' } as any);
|
||||
|
||||
expect(response).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
encodeState,
|
||||
OAuthAdapter,
|
||||
OAuthEnvironmentHandler,
|
||||
OAuthHandlers,
|
||||
OAuthProviderOptions,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
OAuthStartRequest,
|
||||
} from '../../lib/oauth';
|
||||
import { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
} from '../../lib/passport';
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthResolverContext,
|
||||
OAuthStartResponse,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import express from 'express';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
import { commonByEmailResolver } from '../resolvers';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type BitbucketServerOAuthResult = {
|
||||
fullProfile: PassportProfile;
|
||||
params: {
|
||||
scope: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & {
|
||||
host: string;
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
authHandler: AuthHandler<BitbucketServerOAuthResult>;
|
||||
signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export class BitbucketServerAuthProvider implements OAuthHandlers {
|
||||
private readonly signInResolver?: SignInResolver<BitbucketServerOAuthResult>;
|
||||
private readonly authHandler: AuthHandler<BitbucketServerOAuthResult>;
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
private readonly strategy: OAuth2Strategy;
|
||||
private readonly host: string;
|
||||
|
||||
constructor(options: BitbucketServerAuthProviderOptions) {
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.resolverContext = options.resolverContext;
|
||||
this.strategy = new OAuth2Strategy(
|
||||
{
|
||||
authorizationURL: options.authorizationUrl,
|
||||
tokenURL: options.tokenUrl,
|
||||
clientID: options.clientId,
|
||||
clientSecret: options.clientSecret,
|
||||
callbackURL: options.callbackUrl,
|
||||
},
|
||||
(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: any,
|
||||
fullProfile: PassportProfile,
|
||||
done: VerifyCallback,
|
||||
) => {
|
||||
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
|
||||
},
|
||||
);
|
||||
this.host = options.host;
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
|
||||
return await executeRedirectStrategy(req, this.strategy, {
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: req.scope,
|
||||
state: encodeState(req.state),
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
BitbucketServerOAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this.strategy);
|
||||
|
||||
return {
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(
|
||||
req: OAuthRefreshRequest,
|
||||
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this.strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this.strategy,
|
||||
accessToken,
|
||||
);
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(
|
||||
result: BitbucketServerOAuthResult,
|
||||
): Promise<OAuthResponse> {
|
||||
// The OAuth2 strategy does not return a user profile -> let's fetch it before calling the auth handler
|
||||
result.fullProfile = await this.fetchProfile(result);
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
let backstageIdentity = undefined;
|
||||
if (this.signInResolver) {
|
||||
backstageIdentity = await this.signInResolver(
|
||||
{ result, profile },
|
||||
this.resolverContext,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
backstageIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchProfile(
|
||||
result: BitbucketServerOAuthResult,
|
||||
): Promise<PassportProfile> {
|
||||
// Get current user name
|
||||
let whoAmIResponse;
|
||||
try {
|
||||
whoAmIResponse = await fetch(
|
||||
`https://${this.host}/plugins/servlet/applinks/whoami`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${result.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to retrieve the username of the logged in user`);
|
||||
}
|
||||
|
||||
// A response.ok check here would be worthless as the Bitbucket API always returns 200 OK for this call
|
||||
const username = whoAmIResponse.headers.get('X-Ausername');
|
||||
if (!username) {
|
||||
throw new Error(`Failed to retrieve the username of the logged in user`);
|
||||
}
|
||||
|
||||
let userResponse;
|
||||
try {
|
||||
userResponse = await fetch(
|
||||
`https://${this.host}/rest/api/latest/users/${username}?avatarSize=256`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${result.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to retrieve the user '${username}'`);
|
||||
}
|
||||
|
||||
if (!userResponse.ok) {
|
||||
throw new Error(`Failed to retrieve the user '${username}'`);
|
||||
}
|
||||
|
||||
const user = (await userResponse.json()) as any;
|
||||
|
||||
const passportProfile = {
|
||||
provider: 'bitbucketServer',
|
||||
id: user.id.toString(),
|
||||
displayName: user.displayName,
|
||||
username: user.name,
|
||||
emails: [
|
||||
{
|
||||
value: user.emailAddress,
|
||||
},
|
||||
],
|
||||
} as PassportProfile;
|
||||
|
||||
if (user.avatarUrl) {
|
||||
passportProfile.photos = [
|
||||
{ value: `https://${this.host}${user.avatarUrl}` },
|
||||
];
|
||||
}
|
||||
|
||||
return passportProfile;
|
||||
}
|
||||
}
|
||||
|
||||
export const bitbucketServer = createAuthProviderIntegration({
|
||||
create(options?: {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<BitbucketServerOAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
signIn?: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<BitbucketServerOAuthResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ providerId, globalConfig, config, resolverContext }) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const host = envConfig.getString('host');
|
||||
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
|
||||
const callbackUrl =
|
||||
customCallbackUrl ||
|
||||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
const authorizationUrl = `https://${host}/rest/oauth2/latest/authorize`;
|
||||
const tokenUrl = `https://${host}/rest/oauth2/latest/token`;
|
||||
|
||||
const authHandler: AuthHandler<BitbucketServerOAuthResult> =
|
||||
options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
});
|
||||
|
||||
const provider = new BitbucketServerAuthProvider({
|
||||
callbackUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
host,
|
||||
authorizationUrl,
|
||||
tokenUrl,
|
||||
authHandler,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
providerId,
|
||||
callbackUrl,
|
||||
});
|
||||
});
|
||||
},
|
||||
resolvers: {
|
||||
/**
|
||||
* Looks up the user by matching their email to the entity email.
|
||||
*/
|
||||
emailMatchingUserEntityProfileEmail:
|
||||
(): SignInResolver<BitbucketServerOAuthResult> => commonByEmailResolver,
|
||||
},
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export type {
|
||||
BitbucketOAuthResult,
|
||||
BitbucketPassportProfile,
|
||||
} from './bitbucket';
|
||||
export type { BitbucketServerOAuthResult } from './bitbucketServer';
|
||||
export type {
|
||||
CloudflareAccessClaims,
|
||||
CloudflareAccessGroup,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { okta } from './okta';
|
||||
import { onelogin } from './onelogin';
|
||||
import { saml } from './saml';
|
||||
import { AuthProviderFactory } from './types';
|
||||
import { bitbucketServer } from './bitbucketServer';
|
||||
|
||||
/**
|
||||
* All built-in auth provider integrations.
|
||||
@@ -42,6 +43,7 @@ export const providers = Object.freeze({
|
||||
auth0,
|
||||
awsAlb,
|
||||
bitbucket,
|
||||
bitbucketServer,
|
||||
cfAccess,
|
||||
gcpIap,
|
||||
github,
|
||||
@@ -76,5 +78,6 @@ export const defaultAuthProviderFactories: {
|
||||
onelogin: onelogin.create(),
|
||||
awsalb: awsAlb.create(),
|
||||
bitbucket: bitbucket.create(),
|
||||
bitbucketServer: bitbucketServer.create(),
|
||||
atlassian: atlassian.create(),
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('DefaultCatalogDatabase', () => {
|
||||
'location:default/root-2',
|
||||
]);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -745,5 +745,39 @@ describe('DefaultProviderDatabase', () => {
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync, %p',
|
||||
async databaseId => {
|
||||
const fakeLogger = { debug: jest.fn() };
|
||||
const { knex, db } = await createDatabase(
|
||||
databaseId,
|
||||
fakeLogger as any,
|
||||
);
|
||||
|
||||
await createLocations(knex, ['component:default/a']);
|
||||
|
||||
await insertRefRow(knex, {
|
||||
source_key: 'a',
|
||||
target_entity_ref: 'component:default/a',
|
||||
});
|
||||
await insertRefRow(knex, {
|
||||
source_key: 'a',
|
||||
target_entity_ref: 'component:default/a',
|
||||
});
|
||||
|
||||
await db.transaction(async tx => {
|
||||
await db.replaceUnprocessedEntities(tx, {
|
||||
type: 'full',
|
||||
sourceKey: 'a',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = await knex<DbRefreshStateRow>('refresh_state').select();
|
||||
expect(state).toEqual([]);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2023 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
import * as uuid from 'uuid';
|
||||
import { applyDatabaseMigrations } from '../../migrations';
|
||||
import { DbRefreshStateReferencesRow, DbRefreshStateRow } from '../../tables';
|
||||
import { deleteWithEagerPruningOfChildren } from './deleteWithEagerPruningOfChildren';
|
||||
|
||||
describe('deleteWithEagerPruningOfChildren', () => {
|
||||
const databases = TestDatabases.create({
|
||||
ids: ['MYSQL_8', 'POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
|
||||
});
|
||||
|
||||
async function createDatabase(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
return knex;
|
||||
}
|
||||
|
||||
async function run(
|
||||
knex: Knex,
|
||||
options: { entityRefs: string[]; sourceKey: string },
|
||||
): Promise<number> {
|
||||
let result: number;
|
||||
await knex.transaction(
|
||||
async tx => {
|
||||
// We can't return here, as knex swallows the return type in case the
|
||||
// transaction is rolled back:
|
||||
// https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136
|
||||
result = await deleteWithEagerPruningOfChildren({ tx, ...options });
|
||||
},
|
||||
{
|
||||
// If we explicitly trigger a rollback, don't fail.
|
||||
doNotRejectOnRollback: true,
|
||||
},
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
async function insertReference(
|
||||
knex: Knex,
|
||||
...refs: DbRefreshStateReferencesRow[]
|
||||
) {
|
||||
return knex<DbRefreshStateReferencesRow>('refresh_state_references').insert(
|
||||
refs,
|
||||
);
|
||||
}
|
||||
|
||||
async function insertEntity(knex: Knex, ...entityRefs: string[]) {
|
||||
for (const ref of entityRefs) {
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: uuid.v4(),
|
||||
entity_ref: ref,
|
||||
unprocessed_entity: '{}',
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function remainingEntities(knex: Knex) {
|
||||
const rows = await knex<DbRefreshStateRow>('refresh_state')
|
||||
.orderBy('entity_ref')
|
||||
.select('entity_ref');
|
||||
return rows.map(r => r.entity_ref);
|
||||
}
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works for the simple path, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1 - E1 - E2
|
||||
|
||||
P1 - E3
|
||||
|
||||
P1 - E4
|
||||
|
||||
P2 - E5
|
||||
|
||||
Scenario: P1 issues delete for E1 and E3
|
||||
|
||||
Result: E1, E2, and E3 deleted; E4 and E5 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E1', target_entity_ref: 'E2' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E3' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E4' },
|
||||
{ source_key: 'P2', target_entity_ref: 'E5' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E1', 'E3'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E4', 'E5']);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'works when there are multiple identical references, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1
|
||||
\
|
||||
E1
|
||||
/
|
||||
P1
|
||||
|
||||
P1 - E2
|
||||
|
||||
Scenario: P1 issues delete for E1
|
||||
|
||||
Result: E1 deleted; E2 remains
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E2' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2']);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'leaves out things that have roots in other source keys, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1 - E1
|
||||
\
|
||||
E2
|
||||
/
|
||||
P2 - E3
|
||||
|
||||
Scenario: P1 issues delete for E1
|
||||
|
||||
Result: E1 deleted; E2 and E3 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E1', target_entity_ref: 'E2' },
|
||||
{ source_key: 'P2', target_entity_ref: 'E3' },
|
||||
{ source_entity_ref: 'E3', target_entity_ref: 'E2' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'leaves out things that have several different roots for the same source key, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1 - E1
|
||||
\
|
||||
E2
|
||||
/
|
||||
P1 - E3
|
||||
|
||||
Scenario: P1 issues delete for E1
|
||||
|
||||
Result: E1 deleted; E2 and E3 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E1', target_entity_ref: 'E2' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E3' },
|
||||
{ source_entity_ref: 'E3', target_entity_ref: 'E2' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'handles cycles and diamonds gracefully, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1 - E1 <-> E2
|
||||
\
|
||||
E4 E6
|
||||
/ \ /
|
||||
P1 -- E3 -- E5
|
||||
|
||||
Scenario: P1 issues delete for E1, then for E3
|
||||
|
||||
Result: Everything deleted, but in two steps
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5', 'E6');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E1', target_entity_ref: 'E2' },
|
||||
{ source_entity_ref: 'E2', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E2', target_entity_ref: 'E6' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E3' },
|
||||
{ source_entity_ref: 'E3', target_entity_ref: 'E4' },
|
||||
{ source_entity_ref: 'E4', target_entity_ref: 'E5' },
|
||||
{ source_entity_ref: 'E3', target_entity_ref: 'E5' },
|
||||
{ source_entity_ref: 'E5', target_entity_ref: 'E6' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual([
|
||||
'E3',
|
||||
'E4',
|
||||
'E5',
|
||||
'E6',
|
||||
]);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E3'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual([]);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'silently ignores attempts to delete things that are not your own and/or are not roots, %p',
|
||||
async databaseId => {
|
||||
/*
|
||||
P1 - E1 - E2
|
||||
|
||||
P1 - E3
|
||||
|
||||
P2 - E4
|
||||
|
||||
Scenario: P1 issues delete for E2, E3, and E4
|
||||
|
||||
Result: E3 is deleted; E1, E2 and E4 remain
|
||||
*/
|
||||
const knex = await createDatabase(databaseId);
|
||||
await insertEntity(knex, 'E1', 'E2', 'E3', 'E4');
|
||||
await insertReference(
|
||||
knex,
|
||||
{ source_key: 'P1', target_entity_ref: 'E1' },
|
||||
{ source_entity_ref: 'E1', target_entity_ref: 'E2' },
|
||||
{ source_key: 'P1', target_entity_ref: 'E3' },
|
||||
{ source_key: 'P2', target_entity_ref: 'E4' },
|
||||
);
|
||||
await run(knex, { sourceKey: 'P1', entityRefs: ['E2', 'E3', 'E4'] });
|
||||
await expect(remainingEntities(knex)).resolves.toEqual([
|
||||
'E1',
|
||||
'E2',
|
||||
'E4',
|
||||
]);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import { DbRefreshStateReferencesRow, DbRefreshStateRow } from '../../tables';
|
||||
import { DbRefreshStateReferencesRow } from '../../tables';
|
||||
|
||||
/**
|
||||
* Given a number of entity refs originally created by a given entity provider
|
||||
@@ -31,116 +31,157 @@ export async function deleteWithEagerPruningOfChildren(options: {
|
||||
sourceKey: string;
|
||||
}): Promise<number> {
|
||||
const { tx, entityRefs, sourceKey } = options;
|
||||
let removedCount = 0;
|
||||
|
||||
const rootId = () =>
|
||||
tx.raw(
|
||||
tx.client.config.client.includes('mysql')
|
||||
? 'CAST(NULL as UNSIGNED INT)'
|
||||
: 'CAST(NULL as INT)',
|
||||
[],
|
||||
);
|
||||
|
||||
// Split up the operation by (large) chunks, so that we do not hit database
|
||||
// limits for the number of permitted bindings on a precompiled statement
|
||||
let removedCount = 0;
|
||||
for (const refs of lodash.chunk(entityRefs, 1000)) {
|
||||
/*
|
||||
WITH RECURSIVE
|
||||
-- All the nodes that can be reached downwards from our root
|
||||
descendants(root_id, entity_ref) AS (
|
||||
SELECT id, target_entity_ref
|
||||
FROM refresh_state_references
|
||||
WHERE source_key = "R1" AND target_entity_ref = "A"
|
||||
UNION
|
||||
SELECT descendants.root_id, target_entity_ref
|
||||
FROM descendants
|
||||
JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref
|
||||
),
|
||||
-- All the nodes that can be reached upwards from the descendants
|
||||
ancestors(root_id, via_entity_ref, to_entity_ref) AS (
|
||||
SELECT CAST(NULL as INT), entity_ref, entity_ref
|
||||
FROM descendants
|
||||
UNION
|
||||
SELECT
|
||||
CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END,
|
||||
source_entity_ref,
|
||||
ancestors.to_entity_ref
|
||||
FROM ancestors
|
||||
JOIN refresh_state_references ON target_entity_ref = ancestors.via_entity_ref
|
||||
)
|
||||
-- Start out with all of the descendants
|
||||
SELECT descendants.entity_ref
|
||||
FROM descendants
|
||||
-- Expand with all ancestors that point to those, but aren't the current root
|
||||
LEFT OUTER JOIN ancestors
|
||||
ON ancestors.to_entity_ref = descendants.entity_ref
|
||||
AND ancestors.root_id IS NOT NULL
|
||||
AND ancestors.root_id != descendants.root_id
|
||||
-- Exclude all lines that had such a foreign ancestor
|
||||
WHERE ancestors.root_id IS NULL;
|
||||
*/
|
||||
removedCount += await tx<DbRefreshStateRow>('refresh_state')
|
||||
.whereIn('entity_ref', function orphanedEntityRefs(orphans) {
|
||||
return (
|
||||
orphans
|
||||
// All the nodes that can be reached downwards from our root
|
||||
.withRecursive('descendants', function descendants(outer) {
|
||||
return outer
|
||||
.select({ root_id: 'id', entity_ref: 'target_entity_ref' })
|
||||
.from('refresh_state_references')
|
||||
.where('source_key', sourceKey)
|
||||
.whereIn('target_entity_ref', refs)
|
||||
.union(function recursive(inner) {
|
||||
return inner
|
||||
.select({
|
||||
root_id: 'descendants.root_id',
|
||||
entity_ref: 'refresh_state_references.target_entity_ref',
|
||||
})
|
||||
.from('descendants')
|
||||
.join('refresh_state_references', {
|
||||
'descendants.entity_ref':
|
||||
'refresh_state_references.source_entity_ref',
|
||||
});
|
||||
});
|
||||
})
|
||||
// All the nodes that can be reached upwards from the descendants
|
||||
.withRecursive('ancestors', function ancestors(outer) {
|
||||
return outer
|
||||
.select({
|
||||
root_id: rootId(),
|
||||
via_entity_ref: 'entity_ref',
|
||||
to_entity_ref: 'entity_ref',
|
||||
})
|
||||
removedCount += await tx
|
||||
.delete()
|
||||
.from('refresh_state')
|
||||
.whereIn('entity_ref', orphans =>
|
||||
orphans
|
||||
// First find all nodes that can be reached downwards from the roots
|
||||
// (deletion targets), including the roots themselves, by traversing
|
||||
// down the refresh_state_references table. Note that this query
|
||||
// starts with a condition that source_key = our source key, and
|
||||
// target_entity_ref is one of the deletion targets. This has two
|
||||
// effects: it won't match attempts at deleting something that didn't
|
||||
// originate from us in the first place, and also won't match non-root
|
||||
// entities (source_key would be null for those).
|
||||
//
|
||||
// KeyA - R1 - R2 Legend:
|
||||
// \ -----------------------------------------
|
||||
// R3 Key* Source key
|
||||
// / R* Entity ref
|
||||
// KeyA - R4 - R5 lines Individual references; sources to
|
||||
// / the left and targets to the right
|
||||
// KeyB --- R6
|
||||
//
|
||||
// The scenario is that KeyA wants to delete R1.
|
||||
//
|
||||
// The query starts with the KeyA-R1 reference, and then traverses
|
||||
// down to also find R2 and R3. It uses union instead of union all,
|
||||
// because it wants to find the set of unique descendants even if
|
||||
// the tree has unexpected loops etc.
|
||||
.withRecursive('descendants', ['entity_ref'], initial =>
|
||||
initial
|
||||
.select('target_entity_ref')
|
||||
.from('refresh_state_references')
|
||||
.where('source_key', '=', sourceKey)
|
||||
.whereIn('target_entity_ref', refs)
|
||||
.union(recursive =>
|
||||
recursive
|
||||
.select('refresh_state_references.target_entity_ref')
|
||||
.from('descendants')
|
||||
.join(
|
||||
'refresh_state_references',
|
||||
'descendants.entity_ref',
|
||||
'refresh_state_references.source_entity_ref',
|
||||
),
|
||||
),
|
||||
)
|
||||
// Then for each descendant, traverse all the way back upwards through
|
||||
// the refresh_state_references table to get an exhaustive list of all
|
||||
// references that are part of keeping that particular descendant
|
||||
// alive.
|
||||
//
|
||||
// Continuing the scenario from above, starting from R3, it goes
|
||||
// upwards to find every pair along every relation line.
|
||||
//
|
||||
// Top branch: R2-R3, R1-R2, KeyA-R1
|
||||
// Middle branch: R5-R3, R4-R5, KeyA-R4
|
||||
// Bottom branch: R6-R5, KeyB-R6
|
||||
//
|
||||
// Note that this all applied to the subject R3. The exact same thing
|
||||
// will be done starting from each other descendant (R2 and R1). They
|
||||
// only have one and two references to find, respectively.
|
||||
//
|
||||
// This query also uses union instead of union all, to get the set of
|
||||
// distinct relations even if the tree has unexpected loops etc.
|
||||
.withRecursive(
|
||||
'ancestors',
|
||||
['source_key', 'source_entity_ref', 'target_entity_ref', 'subject'],
|
||||
initial =>
|
||||
initial
|
||||
.select(
|
||||
'refresh_state_references.source_key',
|
||||
'refresh_state_references.source_entity_ref',
|
||||
'refresh_state_references.target_entity_ref',
|
||||
'descendants.entity_ref',
|
||||
)
|
||||
.from('descendants')
|
||||
.union(function recursive(inner) {
|
||||
return inner
|
||||
.select({
|
||||
root_id: tx.raw(
|
||||
'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END',
|
||||
[],
|
||||
),
|
||||
via_entity_ref: 'source_entity_ref',
|
||||
to_entity_ref: 'ancestors.to_entity_ref',
|
||||
})
|
||||
.join(
|
||||
'refresh_state_references',
|
||||
'refresh_state_references.target_entity_ref',
|
||||
'descendants.entity_ref',
|
||||
)
|
||||
.union(recursive =>
|
||||
recursive
|
||||
.select(
|
||||
'refresh_state_references.source_key',
|
||||
'refresh_state_references.source_entity_ref',
|
||||
'refresh_state_references.target_entity_ref',
|
||||
'ancestors.subject',
|
||||
)
|
||||
.from('ancestors')
|
||||
.join('refresh_state_references', {
|
||||
target_entity_ref: 'ancestors.via_entity_ref',
|
||||
});
|
||||
});
|
||||
})
|
||||
// Start out with all of the descendants
|
||||
.select('descendants.entity_ref')
|
||||
.from('descendants')
|
||||
// Expand with all ancestors that point to those, but aren't the current root
|
||||
.leftOuterJoin('ancestors', function keepaliveRoots() {
|
||||
this.on('ancestors.to_entity_ref', '=', 'descendants.entity_ref');
|
||||
this.andOnNotNull('ancestors.root_id');
|
||||
this.andOn('ancestors.root_id', '!=', 'descendants.root_id');
|
||||
})
|
||||
.whereNull('ancestors.root_id')
|
||||
);
|
||||
})
|
||||
.delete();
|
||||
.join(
|
||||
'refresh_state_references',
|
||||
'refresh_state_references.target_entity_ref',
|
||||
'ancestors.source_entity_ref',
|
||||
),
|
||||
),
|
||||
)
|
||||
// Finally, from that list of ancestor relations per descendant, pick
|
||||
// out the ones that are roots (have a source_key). Specifically, find
|
||||
// ones that seem to be be either (1) from another source, or (2)
|
||||
// aren't part of the deletion targets. Those are markers that tell us
|
||||
// that the corresponding descendant should be kept alive and NOT
|
||||
// subject to eager deletion, because there's "something else" (not
|
||||
// targeted for deletion) that has references down through the tree to
|
||||
// it.
|
||||
//
|
||||
// Continuing the scenario from above, for R3 we have
|
||||
//
|
||||
// KeyA-R1, KeyA-R4, KeyB-R6
|
||||
//
|
||||
// This tells us that R3 should be kept alive for two reasons: it's
|
||||
// referenced by a node that isn't being deleted (R4), and also by
|
||||
// another source (KeyB). What about R1 and R2? They both have
|
||||
//
|
||||
// KeyA-R1
|
||||
//
|
||||
// So those should be deleted, since they are definitely only being
|
||||
// kept alive by something that's about to be deleted.
|
||||
//
|
||||
// Final shape of the tree:
|
||||
//
|
||||
// R3
|
||||
// /
|
||||
// KeyA - R4 - R5
|
||||
// /
|
||||
// KeyB --- R6
|
||||
.with('retained', ['entity_ref'], notPartOfDeletion =>
|
||||
notPartOfDeletion
|
||||
.select('subject')
|
||||
.from('ancestors')
|
||||
.whereNotNull('ancestors.source_key')
|
||||
.where(foreignKeyOrRef =>
|
||||
foreignKeyOrRef
|
||||
.where('ancestors.source_key', '!=', sourceKey)
|
||||
.orWhereNotIn('ancestors.target_entity_ref', refs),
|
||||
),
|
||||
)
|
||||
// Return all descendants minus the retained ones
|
||||
.select('descendants.entity_ref')
|
||||
.from('descendants')
|
||||
.leftOuterJoin(
|
||||
'retained',
|
||||
'retained.entity_ref',
|
||||
'descendants.entity_ref',
|
||||
)
|
||||
.whereNull('retained.entity_ref'),
|
||||
);
|
||||
|
||||
// Delete the references that originate only from this entity provider. Note
|
||||
// that there may be more than one entity provider making a "claim" for a
|
||||
|
||||
@@ -35,6 +35,10 @@ const entity = {
|
||||
};
|
||||
|
||||
const location: Record<string, LocationSpec> = {
|
||||
v: {
|
||||
type: 'url',
|
||||
target: 'https://github.com/b/c/blob/master/.folder/v.yaml',
|
||||
},
|
||||
w: {
|
||||
type: 'url',
|
||||
target: 'https://github.com/b/c/blob/master/w.yaml',
|
||||
@@ -263,4 +267,20 @@ describe('DefaultCatalogRulesEnforcer', () => {
|
||||
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow locations with a hidden folder', () => {
|
||||
const enforcer = DefaultCatalogRulesEnforcer.fromConfig(
|
||||
new ConfigReader({
|
||||
catalog: {
|
||||
rules: [
|
||||
{
|
||||
allow: ['Component'],
|
||||
locations: [{ type: 'url', pattern: 'https://github.com/b/**' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(enforcer.isAllowed(entity.component, location.v)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,7 +186,10 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer {
|
||||
}
|
||||
if (
|
||||
matcher.pattern &&
|
||||
!minimatch(location?.target, matcher.pattern, { nocase: true })
|
||||
!minimatch(location?.target, matcher.pattern, {
|
||||
nocase: true,
|
||||
dot: true,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,50 @@
|
||||
# Entity Feedback Backend
|
||||
|
||||
Welcome to the entity-feedback backend plugin!
|
||||
|
||||
## Installation
|
||||
|
||||
### Install the package
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn add --cwd packages/backend @backstage/plugin-entity-feedback-backend
|
||||
```
|
||||
|
||||
### Adding the plugin to your `packages/backend`
|
||||
|
||||
You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/entityFeedback.ts`
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@backstage/plugin-entity-feedback-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default function createPlugin(env: PluginEnvironment): Promise<Router> {
|
||||
return createRouter({
|
||||
database: env.database,
|
||||
discovery: env.discovery,
|
||||
identity: env.identity,
|
||||
logger: env.logger,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
With the `entityFeedback.ts` router setup in place, add the router to `packages/backend/src/index.ts`:
|
||||
|
||||
```diff
|
||||
+import entityFeedback from './plugins/entityFeedback';
|
||||
|
||||
async function main() {
|
||||
...
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
+ const entityFeedbackEnv = useHotMemoize(module, () => createEnv('entityFeedback'));
|
||||
|
||||
const apiRouter = Router();
|
||||
+ apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv));
|
||||
...
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
## API Report File for "@backstage/plugin-entity-feedback-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { Logger } from 'winston';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
|
||||
// @public (undocumented)
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
database: PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
identity: IdentityApi;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.createTable('ratings', table => {
|
||||
table.comment('Ratings table');
|
||||
table
|
||||
.string('entity_ref')
|
||||
.notNullable()
|
||||
.comment('The ref of the entity being rated');
|
||||
table
|
||||
.string('user_ref')
|
||||
.notNullable()
|
||||
.comment('The user applying the rating');
|
||||
table.string('rating').notNullable().comment('The rating value');
|
||||
table
|
||||
.timestamp('timestamp')
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
.comment('When the rating was recorded');
|
||||
});
|
||||
|
||||
await knex.schema.createTable('responses', table => {
|
||||
table.comment('Responses table');
|
||||
table
|
||||
.string('entity_ref')
|
||||
.notNullable()
|
||||
.comment('The ref of the applicable entity');
|
||||
table.string('user_ref').notNullable().comment('The user responding');
|
||||
table.text('response').comment('The serialized response');
|
||||
table.text('comments').comment('Additional user comments');
|
||||
table
|
||||
.boolean('consent')
|
||||
.defaultTo(true)
|
||||
.notNullable()
|
||||
.comment('Whether user (if recorded) consents to being contacted');
|
||||
table
|
||||
.timestamp('timestamp')
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
.comment('When the response was recorded');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.dropTable('ratings');
|
||||
await knex.schema.dropTable('responses');
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@backstage/plugin-entity-feedback-backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-entity-feedback-common": "workspace:^",
|
||||
"@types/express": "*",
|
||||
"express": "^4.18.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"knex": "^2.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"msw": "^0.49.0",
|
||||
"supertest": "^6.2.4"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Entity Feedback backend plugin
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from './service/router';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2023 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 { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2023 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import { DatabaseHandler } from './DatabaseHandler';
|
||||
|
||||
describe('DatabaseHandler', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabaseHandler(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
return {
|
||||
knex,
|
||||
dbHandler: await DatabaseHandler.create({ database: knex }),
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'%p',
|
||||
databaseId => {
|
||||
let knex: Knex;
|
||||
let dbHandler: DatabaseHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ knex, dbHandler } = await createDatabaseHandler(databaseId));
|
||||
}, 30000);
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await knex('ratings').del();
|
||||
await knex('responses').del();
|
||||
}, 30000);
|
||||
|
||||
it('getAllRatedEntities', async () => {
|
||||
await knex('ratings').insert([
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'DISLIKE',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/library',
|
||||
user_ref: 'user:default/bar',
|
||||
rating: 'LIKE',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/website',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
},
|
||||
]);
|
||||
|
||||
const entities = await dbHandler.getAllRatedEntities();
|
||||
expect(entities.length).toEqual(3);
|
||||
expect(entities).toEqual(
|
||||
expect.arrayContaining([
|
||||
'component:default/service',
|
||||
'component:default/library',
|
||||
'component:default/website',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('getRatingsAggregates', async () => {
|
||||
await knex('ratings').insert([
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'DISLIKE',
|
||||
timestamp: '2004-10-19 10:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-19 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/bar',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/website',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/website',
|
||||
user_ref: 'user:default/test',
|
||||
rating: 'DISLIKE',
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
]);
|
||||
|
||||
let ratings = await dbHandler.getRatingsAggregates([
|
||||
'component:default/service',
|
||||
'component:default/website',
|
||||
]);
|
||||
expect(ratings.length).toEqual(3);
|
||||
expect(ratings).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entityRef: 'component:default/service',
|
||||
rating: 'LIKE',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/website',
|
||||
rating: 'DISLIKE',
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/website',
|
||||
rating: 'LIKE',
|
||||
count: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
ratings = await dbHandler.getRatingsAggregates([
|
||||
'component:default/website',
|
||||
]);
|
||||
expect(ratings.length).toEqual(2);
|
||||
expect(ratings).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
entityRef: 'component:default/website',
|
||||
rating: 'DISLIKE',
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/website',
|
||||
rating: 'LIKE',
|
||||
count: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('recordRating', async () => {
|
||||
const newRating = {
|
||||
userRef: 'user:default/me',
|
||||
entityRef: 'component:default/service',
|
||||
rating: 'LIKE',
|
||||
};
|
||||
|
||||
await dbHandler.recordRating(newRating);
|
||||
|
||||
const ratings = await knex('ratings').select();
|
||||
expect(ratings.length).toEqual(1);
|
||||
expect(ratings[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
user_ref: newRating.userRef,
|
||||
entity_ref: newRating.entityRef,
|
||||
rating: newRating.rating,
|
||||
timestamp: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('getRatings', async () => {
|
||||
await knex('ratings').insert([
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'DISLIKE',
|
||||
timestamp: '2004-10-19 10:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-19 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/bar',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/website',
|
||||
user_ref: 'user:default/foo',
|
||||
rating: 'LIKE',
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
]);
|
||||
|
||||
const ratings = await dbHandler.getRatings('component:default/service');
|
||||
expect(ratings.length).toEqual(2);
|
||||
expect(ratings).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ userRef: 'user:default/foo', rating: 'LIKE' },
|
||||
{ userRef: 'user:default/bar', rating: 'LIKE' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('recordResponse', async () => {
|
||||
const newResponse = {
|
||||
userRef: 'user:default/me',
|
||||
entityRef: 'component:default/service',
|
||||
response: 'blah',
|
||||
comments: 'here is some feedback',
|
||||
consent: false,
|
||||
};
|
||||
|
||||
await dbHandler.recordResponse(newResponse);
|
||||
|
||||
const responses = await knex('responses').select();
|
||||
expect(responses.length).toEqual(1);
|
||||
expect({
|
||||
...responses[0],
|
||||
consent: Boolean(responses[0].consent),
|
||||
}).toEqual(
|
||||
expect.objectContaining({
|
||||
user_ref: newResponse.userRef,
|
||||
entity_ref: newResponse.entityRef,
|
||||
response: newResponse.response,
|
||||
comments: newResponse.comments,
|
||||
consent: newResponse.consent,
|
||||
timestamp: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('getResponses', async () => {
|
||||
await knex('responses').insert([
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
response: 'blah',
|
||||
comments: 'here is some feedback',
|
||||
consent: true,
|
||||
timestamp: '2004-10-19 10:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/foo',
|
||||
response: 'asdf',
|
||||
comments: 'here is new feedback',
|
||||
consent: false,
|
||||
timestamp: '2004-11-19 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/service',
|
||||
user_ref: 'user:default/bar',
|
||||
response: 'noop',
|
||||
comments: 'here is different feedback',
|
||||
consent: true,
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
{
|
||||
entity_ref: 'component:default/website',
|
||||
user_ref: 'user:default/foo',
|
||||
response: 'cool',
|
||||
comments: 'no comment',
|
||||
consent: true,
|
||||
timestamp: '2004-11-20 08:23:54',
|
||||
},
|
||||
]);
|
||||
|
||||
const responses = await dbHandler.getResponses(
|
||||
'component:default/service',
|
||||
);
|
||||
expect(responses.length).toEqual(2);
|
||||
expect(responses).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userRef: 'user:default/foo',
|
||||
response: 'asdf',
|
||||
comments: 'here is new feedback',
|
||||
consent: false,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/bar',
|
||||
response: 'noop',
|
||||
comments: 'here is different feedback',
|
||||
consent: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2023 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 { resolvePackagePath } from '@backstage/backend-common';
|
||||
import {
|
||||
FeedbackResponse,
|
||||
Rating,
|
||||
} from '@backstage/plugin-entity-feedback-common';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-entity-feedback-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
type RatingsAggregatesResult = {
|
||||
entityRef: string;
|
||||
rating: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
type Options = {
|
||||
database: Knex;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class DatabaseHandler {
|
||||
static async create(options: Options): Promise<DatabaseHandler> {
|
||||
const { database } = options;
|
||||
|
||||
await database.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
|
||||
return new DatabaseHandler(options);
|
||||
}
|
||||
|
||||
private readonly database: Knex;
|
||||
|
||||
private constructor(options: Options) {
|
||||
this.database = options.database;
|
||||
}
|
||||
|
||||
async getAllRatedEntities(): Promise<string[]> {
|
||||
return (await this.database('ratings').distinct('entity_ref')).map(
|
||||
({ entity_ref }) => entity_ref,
|
||||
);
|
||||
}
|
||||
|
||||
async getRatingsAggregates(
|
||||
entityRefs: string[],
|
||||
): Promise<RatingsAggregatesResult> {
|
||||
return (
|
||||
await this.database('ratings')
|
||||
.whereIn('ratings.entity_ref', entityRefs)
|
||||
.innerJoin(
|
||||
this.database('ratings')
|
||||
.as('latest_ratings')
|
||||
.select('entity_ref', 'user_ref')
|
||||
.max('timestamp', { as: 'timestamp' })
|
||||
.whereIn('entity_ref', entityRefs)
|
||||
.groupBy('entity_ref', 'user_ref'),
|
||||
function joinClause() {
|
||||
this.on('ratings.entity_ref', '=', 'latest_ratings.entity_ref')
|
||||
.andOn('ratings.user_ref', '=', 'latest_ratings.user_ref')
|
||||
.andOn('ratings.timestamp', '=', 'latest_ratings.timestamp');
|
||||
},
|
||||
)
|
||||
.select('ratings.entity_ref', 'rating')
|
||||
.count('ratings.user_ref as count')
|
||||
.groupBy('ratings.entity_ref', 'rating')
|
||||
).map(({ entity_ref, rating, count }) => ({
|
||||
entityRef: entity_ref as string,
|
||||
rating: rating as string,
|
||||
count: Number(count),
|
||||
}));
|
||||
}
|
||||
|
||||
async recordRating(rating: Rating) {
|
||||
await this.database('ratings').insert({
|
||||
entity_ref: rating.entityRef,
|
||||
rating: rating.rating,
|
||||
user_ref: rating.userRef,
|
||||
});
|
||||
}
|
||||
|
||||
async getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]> {
|
||||
return (
|
||||
await this.database('ratings')
|
||||
.where('entity_ref', entityRef)
|
||||
.innerJoin(
|
||||
this.database('ratings')
|
||||
.as('latest_ratings')
|
||||
.select('user_ref')
|
||||
.max('timestamp', { as: 'timestamp' })
|
||||
.where('entity_ref', entityRef)
|
||||
.groupBy('user_ref'),
|
||||
function joinClause() {
|
||||
this.on('ratings.user_ref', '=', 'latest_ratings.user_ref').andOn(
|
||||
'ratings.timestamp',
|
||||
'=',
|
||||
'latest_ratings.timestamp',
|
||||
);
|
||||
},
|
||||
)
|
||||
.select('ratings.user_ref', 'rating')
|
||||
).map(rating => ({ userRef: rating.user_ref, rating: rating.rating }));
|
||||
}
|
||||
|
||||
async recordResponse(response: FeedbackResponse) {
|
||||
await this.database('responses').insert({
|
||||
entity_ref: response.entityRef,
|
||||
response: response.response,
|
||||
comments: response.comments,
|
||||
consent: response.consent,
|
||||
user_ref: response.userRef,
|
||||
});
|
||||
}
|
||||
|
||||
async getResponses(
|
||||
entityRef: string,
|
||||
): Promise<Omit<FeedbackResponse, 'entityRef'>[]> {
|
||||
return (
|
||||
await this.database('responses')
|
||||
.where('entity_ref', entityRef)
|
||||
.innerJoin(
|
||||
this.database('responses')
|
||||
.as('latest_responses')
|
||||
.select('user_ref')
|
||||
.max('timestamp', { as: 'timestamp' })
|
||||
.where('entity_ref', entityRef)
|
||||
.groupBy('user_ref'),
|
||||
function joinClause() {
|
||||
this.on(
|
||||
'responses.user_ref',
|
||||
'=',
|
||||
'latest_responses.user_ref',
|
||||
).andOn('responses.timestamp', '=', 'latest_responses.timestamp');
|
||||
},
|
||||
)
|
||||
.select('responses.user_ref', 'response', 'comments', 'consent')
|
||||
).map(response => ({
|
||||
userRef: response.user_ref,
|
||||
response: response.response,
|
||||
comments: response.comments,
|
||||
consent: Boolean(response.consent),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
DatabaseManager,
|
||||
getVoidLogger,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
const sampleOwnedEntities = [
|
||||
{
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'foo',
|
||||
title: 'Foo Component',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'bar',
|
||||
title: 'Bar Component',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sampleEntities = [
|
||||
...sampleOwnedEntities,
|
||||
{
|
||||
kind: 'user',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'foo',
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
kind: 'user',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'bar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockGetEntties = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ items: sampleOwnedEntities }));
|
||||
|
||||
const mockGetEnttiesByRefs = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ items: sampleEntities }));
|
||||
|
||||
jest.mock('@backstage/catalog-client', () => ({
|
||||
CatalogClient: jest.fn().mockImplementation(() => ({
|
||||
getEntities: mockGetEntties,
|
||||
getEntitiesByRefs: mockGetEnttiesByRefs,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@backstage/plugin-auth-node', () => ({
|
||||
getBearerTokenFromAuthorizationHeader: () => 'token',
|
||||
}));
|
||||
|
||||
const mockRatings = [
|
||||
{ userRef: 'user:default/foo', rating: 'LIKE' },
|
||||
{ userRef: 'user:default/bar', rating: 'LIKE' },
|
||||
{ userRef: 'user:default/test', rating: 'DISLIKE' },
|
||||
];
|
||||
|
||||
const mockResponses = [
|
||||
{
|
||||
userRef: 'user:default/foo',
|
||||
response: 'asdf',
|
||||
comments: 'here is new feedback',
|
||||
consent: false,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/bar',
|
||||
response: 'noop',
|
||||
comments: 'here is different feedback',
|
||||
consent: true,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/test',
|
||||
response: 'err',
|
||||
comments: 'no comment',
|
||||
consent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDbHandler = {
|
||||
getAllRatedEntities: jest
|
||||
.fn()
|
||||
.mockImplementation(async () => [
|
||||
'component:default/foo',
|
||||
'component:default/bar',
|
||||
'component:default/test',
|
||||
]),
|
||||
getRatingsAggregates: jest.fn().mockImplementation(async () => [
|
||||
{ entityRef: 'component:default/foo', rating: 'LIKE', count: 3 },
|
||||
{ entityRef: 'component:default/foo', rating: 'DISLIKE', count: 1 },
|
||||
{ entityRef: 'component:default/bar', rating: 'LIKE', count: 5 },
|
||||
]),
|
||||
recordRating: jest.fn().mockImplementation(async () => {}),
|
||||
getRatings: jest.fn().mockImplementation(async () => mockRatings),
|
||||
recordResponse: jest.fn().mockImplementation(async () => {}),
|
||||
getResponses: jest.fn().mockImplementation(async () => mockResponses),
|
||||
};
|
||||
|
||||
jest.mock('./DatabaseHandler', () => ({
|
||||
DatabaseHandler: { create: async () => mockDbHandler },
|
||||
}));
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
const createDatabase = () =>
|
||||
DatabaseManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
database: {
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).forPlugin('entity-feedback');
|
||||
|
||||
const mockIdentityClient = {
|
||||
getIdentity: jest.fn().mockImplementation(async () => ({
|
||||
identity: { userEntityRef: 'user:default/me' },
|
||||
})),
|
||||
} as unknown as IdentityApi;
|
||||
|
||||
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
|
||||
getBaseUrl: jest.fn(),
|
||||
getExternalBaseUrl: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const router = await createRouter({
|
||||
database: createDatabase(),
|
||||
discovery,
|
||||
identity: mockIdentityClient,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /ratings', () => {
|
||||
it('should get ratings for all entities correctly', async () => {
|
||||
const response = await request(app).get('/ratings').send();
|
||||
|
||||
expect(mockDbHandler.getAllRatedEntities).toHaveBeenCalled();
|
||||
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
|
||||
sampleEntities
|
||||
.filter(Boolean)
|
||||
.map((ent: any) => stringifyEntityRef(ent)),
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
entityRef: 'component:default/foo',
|
||||
entityTitle: 'Foo Component',
|
||||
ratings: { LIKE: 3, DISLIKE: 1 },
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/bar',
|
||||
entityTitle: 'Bar Component',
|
||||
ratings: { LIKE: 5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get ratings for all owned entities correctly', async () => {
|
||||
const response = await request(app)
|
||||
.get('/ratings?ownerRef=group:default/test-team')
|
||||
.send();
|
||||
|
||||
expect(mockGetEntties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: { 'relations.ownedBy': 'group:default/test-team' },
|
||||
}),
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.getAllRatedEntities).not.toHaveBeenCalled();
|
||||
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
|
||||
sampleOwnedEntities.map((ent: any) => stringifyEntityRef(ent)),
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
entityRef: 'component:default/foo',
|
||||
entityTitle: 'Foo Component',
|
||||
ratings: { LIKE: 3, DISLIKE: 1 },
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/bar',
|
||||
entityTitle: 'Bar Component',
|
||||
ratings: { LIKE: 5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /ratings/:entityRef', () => {
|
||||
it('should record a rating correctly', async () => {
|
||||
const body = { rating: 'LIKE' };
|
||||
const response = await request(app)
|
||||
.post('/ratings/component%3Adefault%2Fservice')
|
||||
.send(body);
|
||||
expect(mockDbHandler.recordRating).toHaveBeenCalledWith({
|
||||
entityRef: 'component:default/service',
|
||||
userRef: 'user:default/me',
|
||||
...body,
|
||||
});
|
||||
expect(response.status).toEqual(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ratings/:entityRef', () => {
|
||||
it('should get ratings for an entity correctly', async () => {
|
||||
const response = await request(app)
|
||||
.get('/ratings/component%3Adefault%2Fservice')
|
||||
.send();
|
||||
expect(mockDbHandler.getRatings).toHaveBeenCalledWith(
|
||||
'component:default/service',
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(
|
||||
mockRatings.filter(r => r.userRef !== 'user:default/test'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /responses/:entityRef', () => {
|
||||
it('should record a response correctly', async () => {
|
||||
const body = { response: 'blah', comments: 'feedback', consent: true };
|
||||
const response = await request(app)
|
||||
.post('/responses/component%3Adefault%2Fservice')
|
||||
.send(body);
|
||||
expect(mockDbHandler.recordResponse).toHaveBeenCalledWith({
|
||||
entityRef: 'component:default/service',
|
||||
userRef: 'user:default/me',
|
||||
...body,
|
||||
});
|
||||
expect(response.status).toEqual(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /responses/:entityRef', () => {
|
||||
it('should get responses for an entity correctly', async () => {
|
||||
const response = await request(app)
|
||||
.get('/responses/component%3Adefault%2Fservice')
|
||||
.send();
|
||||
expect(mockDbHandler.getResponses).toHaveBeenCalledWith(
|
||||
'component:default/service',
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(
|
||||
mockResponses.filter(r => r.userRef !== 'user:default/test'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
errorHandler,
|
||||
PluginDatabaseManager,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
IdentityApi,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { DatabaseHandler } from './DatabaseHandler';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
database: PluginDatabaseManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
identity: IdentityApi;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { database, discovery, identity, logger } = options;
|
||||
|
||||
logger.info('Initializing Entity Feedback backend');
|
||||
|
||||
const catalogClient = new CatalogClient({ discoveryApi: discovery });
|
||||
const db = await database.getClient();
|
||||
const dbHandler = await DatabaseHandler.create({ database: db });
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/ratings', async (req, res) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
const requestedEntities: { [ref: string]: Entity } = {};
|
||||
if (req.query.ownerRef) {
|
||||
// Get ratings from all owned entities (also ensures only accessible entities are requested)
|
||||
(
|
||||
await catalogClient.getEntities(
|
||||
{
|
||||
filter: { 'relations.ownedBy': req.query.ownerRef as string },
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.name',
|
||||
'metadata.namespace',
|
||||
'metadata.title',
|
||||
],
|
||||
},
|
||||
{ token },
|
||||
)
|
||||
).items.forEach(ent => {
|
||||
requestedEntities[stringifyEntityRef(ent)] = ent;
|
||||
});
|
||||
} else {
|
||||
const allRatedEntities = await dbHandler.getAllRatedEntities();
|
||||
|
||||
// Filter entities to only expose entity refs accessible by current user
|
||||
(
|
||||
await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: allRatedEntities,
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.namespace',
|
||||
'metadata.name',
|
||||
'metadata.title',
|
||||
],
|
||||
},
|
||||
{ token },
|
||||
)
|
||||
).items
|
||||
.filter(Boolean)
|
||||
.forEach(ent => {
|
||||
requestedEntities[stringifyEntityRef(ent!)] = ent!;
|
||||
});
|
||||
}
|
||||
|
||||
const entityRatings = await dbHandler.getRatingsAggregates(
|
||||
Object.keys(requestedEntities),
|
||||
);
|
||||
|
||||
// Merge rating aggregates into a condensed per entity structure
|
||||
const entityRatingsMap = entityRatings.reduce<{
|
||||
[ref: string]: EntityRatingsData;
|
||||
}>((ratingsMap, { entityRef, rating, count }) => {
|
||||
ratingsMap[entityRef] = ratingsMap[entityRef] ?? {
|
||||
entityRef,
|
||||
entityTitle: requestedEntities[entityRef].metadata.title,
|
||||
ratings: {},
|
||||
};
|
||||
ratingsMap[entityRef].ratings[rating] = count;
|
||||
return ratingsMap;
|
||||
}, {});
|
||||
|
||||
res.json(Object.values(entityRatingsMap));
|
||||
});
|
||||
|
||||
router.post('/ratings/:entityRef', async (req, res) => {
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
const rating = req.body.rating;
|
||||
if (!user || !rating) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await dbHandler.recordRating({
|
||||
entityRef: req.params.entityRef,
|
||||
rating,
|
||||
userRef: user.identity.userEntityRef,
|
||||
});
|
||||
|
||||
res.status(201).end();
|
||||
});
|
||||
|
||||
router.get('/ratings/:entityRef', async (req, res) => {
|
||||
const ratings = await dbHandler.getRatings(req.params.entityRef);
|
||||
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
// Filter ratings via user refs to only expose entity refs accessible by current user
|
||||
const accessibleEntityRefs = (
|
||||
await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ratings.map(r => r.userRef),
|
||||
fields: ['kind', 'metadata.namespace', 'metadata.name'],
|
||||
},
|
||||
{ token },
|
||||
)
|
||||
).items
|
||||
.filter(Boolean)
|
||||
.map(ent => stringifyEntityRef(ent!));
|
||||
|
||||
res.json(ratings.filter(r => accessibleEntityRefs.includes(r.userRef)));
|
||||
});
|
||||
|
||||
router.post('/responses/:entityRef', async (req, res) => {
|
||||
const user = await identity.getIdentity({ request: req });
|
||||
const { response, comments, consent } = req.body;
|
||||
|
||||
if (!user) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await dbHandler.recordResponse({
|
||||
entityRef: req.params.entityRef,
|
||||
response,
|
||||
comments,
|
||||
consent,
|
||||
userRef: user.identity.userEntityRef,
|
||||
});
|
||||
|
||||
res.status(201).end();
|
||||
});
|
||||
|
||||
router.get('/responses/:entityRef', async (req, res) => {
|
||||
const responses = await dbHandler.getResponses(req.params.entityRef);
|
||||
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
// Filter responses via user refs to only expose entity refs accessible by current user
|
||||
const accessibleEntityRefs = (
|
||||
await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: responses.map(r => r.userRef),
|
||||
fields: ['kind', 'metadata.namespace', 'metadata.name'],
|
||||
},
|
||||
{ token },
|
||||
)
|
||||
).items
|
||||
.filter(Boolean)
|
||||
.map(ent => stringifyEntityRef(ent!));
|
||||
|
||||
res.json(responses.filter(r => accessibleEntityRefs.includes(r.userRef)));
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
createServiceBuilder,
|
||||
DatabaseManager,
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
useHotMemoize,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'entity-feedback-backend' });
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
const manager = DatabaseManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
database: { client: 'better-sqlite3', connection: ':memory:' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return manager.forPlugin('entity-feedback');
|
||||
});
|
||||
|
||||
const identity = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: await discovery.getExternalBaseUrl('auth'),
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
database,
|
||||
discovery,
|
||||
identity,
|
||||
logger,
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/entity-feedback', router);
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Entity Feedback Common
|
||||
|
||||
Common types for the entity-feedback plugin.
|
||||
@@ -0,0 +1,41 @@
|
||||
## API Report File for "@backstage/plugin-entity-feedback-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
// @public (undocumented)
|
||||
export interface EntityRatingsData {
|
||||
// (undocumented)
|
||||
entityRef: string;
|
||||
// (undocumented)
|
||||
entityTitle?: string;
|
||||
// (undocumented)
|
||||
ratings: {
|
||||
[ratingValue: string]: number;
|
||||
};
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FeedbackResponse {
|
||||
// (undocumented)
|
||||
comments?: string;
|
||||
// (undocumented)
|
||||
consent?: boolean;
|
||||
// (undocumented)
|
||||
entityRef: string;
|
||||
// (undocumented)
|
||||
response?: string;
|
||||
// (undocumented)
|
||||
userRef: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface Rating {
|
||||
// (undocumented)
|
||||
entityRef: string;
|
||||
// (undocumented)
|
||||
rating: string;
|
||||
// (undocumented)
|
||||
userRef: string;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@backstage/plugin-entity-feedback-common",
|
||||
"description": "Common functionalities for the entity-feedback plugin",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common functionalities for the entity-feedback plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Rating {
|
||||
entityRef: string;
|
||||
rating: string;
|
||||
userRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface FeedbackResponse {
|
||||
entityRef: string;
|
||||
response?: string;
|
||||
comments?: string;
|
||||
consent?: boolean;
|
||||
userRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface EntityRatingsData {
|
||||
entityRef: string;
|
||||
entityTitle?: string;
|
||||
ratings: {
|
||||
[ratingValue: string]: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,136 @@
|
||||
# Entity Feedback Plugin
|
||||
|
||||
Welcome to the entity-feedback plugin!
|
||||
|
||||
This plugin allows you give and view feedback on entities available in the Backstage catalog.
|
||||
|
||||
## Features
|
||||
|
||||
### Rate entities
|
||||
|
||||
#### Like/Dislike rating
|
||||
|
||||

|
||||
|
||||
#### Starred rating
|
||||
|
||||

|
||||
|
||||
### Request additional feedback when poorly rated
|
||||
|
||||

|
||||
|
||||
### View entity feedback responses
|
||||
|
||||

|
||||
|
||||
### View aggregated ratings on owned entities
|
||||
|
||||
#### Total likes/dislikes
|
||||
|
||||

|
||||
|
||||
#### Star breakdowns
|
||||
|
||||

|
||||
|
||||
## Setup
|
||||
|
||||
The following sections will help you get the Entity Feedback plugin setup and running
|
||||
|
||||
### Backend
|
||||
|
||||
You need to setup the [Entity Feedback backend plugin](https://github.com/backstage/backstage/tree/master/plugins/entity-feedback-backend) before you move forward with any of these steps if you haven't already
|
||||
|
||||
### Installation
|
||||
|
||||
Install this plugin:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/app add @backstage/plugin-entity-feedback
|
||||
```
|
||||
|
||||
### Entity Pages
|
||||
|
||||
Add rating and feedback components to your `EntityPage.tsx` to hook up UI so that users
|
||||
can rate your entities and for owners to view feedback/responses.
|
||||
|
||||
To allow users to apply "Like" and "Dislike" ratings add the following to each kind/type of
|
||||
entity in your `EntityPage.tsx` you want to be rated (if you prefer to use star ratings, replace
|
||||
`EntityLikeDislikeRatingsCard` with `EntityStarredRatingsCard` and `LikeDislikeButtons` with
|
||||
`StarredRatingButtons`):
|
||||
|
||||
```diff
|
||||
import {
|
||||
...
|
||||
+ InfoCard,
|
||||
...
|
||||
} from '@backstage/core-components';
|
||||
+import {
|
||||
+ EntityFeedbackResponseContent,
|
||||
+ EntityLikeDislikeRatingsCard,
|
||||
+ LikeDislikeButtons,
|
||||
+} from '@backstage/plugin-entity-feedback';
|
||||
|
||||
// Add to each applicable kind/type of entity as desired
|
||||
const overviewContent = (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
...
|
||||
+ <Grid item md={2}>
|
||||
+ <InfoCard title="Rate this entity">
|
||||
+ <LikeDislikeButtons />
|
||||
+ </InfoCard>
|
||||
+ </Grid>
|
||||
...
|
||||
</Grid>
|
||||
);
|
||||
|
||||
...
|
||||
|
||||
// Add to each applicable kind/type of entity as desired
|
||||
const serviceEntityPage = (
|
||||
<EntityLayoutWrapper>
|
||||
...
|
||||
+ <EntityLayout.Route path="/feedback" title="Feedback">
|
||||
+ <EntityFeedbackResponseContent />
|
||||
+ </EntityLayout.Route>
|
||||
...
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
...
|
||||
|
||||
// Add ratings card component to user/group entities to view ratings of owned entities
|
||||
const userPage = (
|
||||
<EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
<Grid container spacing={3}>
|
||||
...
|
||||
+ <Grid item xs={12}>
|
||||
+ <EntityLikeDislikeRatingsCard />
|
||||
+ </Grid>
|
||||
...
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
|
||||
const groupPage = (
|
||||
<EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
<Grid container spacing={3}>
|
||||
...
|
||||
+ <Grid item xs={12}>
|
||||
+ <EntityLikeDislikeRatingsCard />
|
||||
+ </Grid>
|
||||
...
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayoutWrapper>
|
||||
);
|
||||
```
|
||||
|
||||
Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx)
|
||||
@@ -0,0 +1,184 @@
|
||||
## API Report File for "@backstage/plugin-entity-feedback"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
|
||||
import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { Rating } from '@backstage/plugin-entity-feedback-common';
|
||||
import { ReactNode } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EntityFeedbackApi {
|
||||
// (undocumented)
|
||||
getAllRatings(): Promise<EntityRatingsData[]>;
|
||||
// (undocumented)
|
||||
getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]>;
|
||||
// (undocumented)
|
||||
getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]>;
|
||||
// (undocumented)
|
||||
getResponses(
|
||||
entityRef: string,
|
||||
): Promise<Omit<FeedbackResponse, 'entityRef'>[]>;
|
||||
// (undocumented)
|
||||
recordRating(entityRef: string, rating: string): Promise<void>;
|
||||
// (undocumented)
|
||||
recordResponse(
|
||||
entityRef: string,
|
||||
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const entityFeedbackApiRef: ApiRef<EntityFeedbackApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class EntityFeedbackClient implements EntityFeedbackApi {
|
||||
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
|
||||
// (undocumented)
|
||||
getAllRatings(): Promise<EntityRatingsData[]>;
|
||||
// (undocumented)
|
||||
getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]>;
|
||||
// (undocumented)
|
||||
getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]>;
|
||||
// (undocumented)
|
||||
getResponses(
|
||||
entityRef: string,
|
||||
): Promise<Omit<FeedbackResponse, 'entityRef'>[]>;
|
||||
// (undocumented)
|
||||
recordRating(entityRef: string, rating: string): Promise<void>;
|
||||
// (undocumented)
|
||||
recordResponse(
|
||||
entityRef: string,
|
||||
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const entityFeedbackPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
},
|
||||
{},
|
||||
{}
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface EntityFeedbackResponse {
|
||||
// (undocumented)
|
||||
id: string;
|
||||
// (undocumented)
|
||||
label: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityFeedbackResponseContent: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityLikeDislikeRatingsCard: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityStarredRatingsCard: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const FeedbackResponseDialog: (
|
||||
props: FeedbackResponseDialogProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FeedbackResponseDialogProps {
|
||||
// (undocumented)
|
||||
entity: Entity;
|
||||
// (undocumented)
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
// (undocumented)
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
// (undocumented)
|
||||
onClose: () => void;
|
||||
// (undocumented)
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const FeedbackResponseTable: (
|
||||
props: FeedbackResponseTableProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FeedbackResponseTableProps {
|
||||
// (undocumented)
|
||||
entityRef: string;
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const LikeDislikeButtons: (
|
||||
props: LikeDislikeButtonsProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface LikeDislikeButtonsProps {
|
||||
// (undocumented)
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
// (undocumented)
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
// (undocumented)
|
||||
requestResponse?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const LikeDislikeRatingsTable: (
|
||||
props: LikeDislikeRatingsTableProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface LikeDislikeRatingsTableProps {
|
||||
// (undocumented)
|
||||
allEntities?: boolean;
|
||||
// (undocumented)
|
||||
ownerRef?: string;
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const StarredRatingButtons: (
|
||||
props: StarredRatingButtonsProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface StarredRatingButtonsProps {
|
||||
// (undocumented)
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
// (undocumented)
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
// (undocumented)
|
||||
requestResponse?: boolean;
|
||||
// (undocumented)
|
||||
requestResponseThreshold?: number;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const StarredRatingsTable: (
|
||||
props: StarredRatingsTableProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface StarredRatingsTableProps {
|
||||
// (undocumented)
|
||||
allEntities?: boolean;
|
||||
// (undocumented)
|
||||
ownerRef?: string;
|
||||
// (undocumented)
|
||||
title?: string;
|
||||
}
|
||||
```
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@backstage/plugin-entity-feedback",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/entity-feedback"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/plugin-entity-feedback-common": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "*",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^0.49.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createApiRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityRatingsData,
|
||||
FeedbackResponse,
|
||||
Rating,
|
||||
} from '@backstage/plugin-entity-feedback-common';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const entityFeedbackApiRef = createApiRef<EntityFeedbackApi>({
|
||||
id: 'plugin.entity-feedback.service',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface EntityFeedbackApi {
|
||||
getAllRatings(): Promise<EntityRatingsData[]>;
|
||||
|
||||
getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]>;
|
||||
|
||||
recordRating(entityRef: string, rating: string): Promise<void>;
|
||||
|
||||
getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]>;
|
||||
|
||||
recordResponse(
|
||||
entityRef: string,
|
||||
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
|
||||
): Promise<void>;
|
||||
|
||||
getResponses(
|
||||
entityRef: string,
|
||||
): Promise<Omit<FeedbackResponse, 'entityRef'>[]>;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2023 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 { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { EntityFeedbackClient } from './EntityFeedbackClient';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
describe('EntityFeedbackClient', () => {
|
||||
setupRequestMockHandlers(server);
|
||||
const mockBaseUrl = 'http://backstage/api/entity-feedback';
|
||||
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
|
||||
const fetchApi = new MockFetchApi();
|
||||
|
||||
let client: EntityFeedbackClient;
|
||||
beforeEach(() => {
|
||||
client = new EntityFeedbackClient({ discoveryApi, fetchApi });
|
||||
});
|
||||
|
||||
it('getAllRatings', async () => {
|
||||
const ratings = [
|
||||
{
|
||||
entityRef: 'component:default/foo',
|
||||
entityTitle: 'Foo',
|
||||
ratings: { LIKE: 10 },
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/bar',
|
||||
entityTitle: 'Bar',
|
||||
ratings: { DISLIKE: 10 },
|
||||
},
|
||||
];
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/ratings`, (_, res, ctx) =>
|
||||
res(ctx.json(ratings)),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await client.getAllRatings();
|
||||
expect(response).toEqual(ratings);
|
||||
});
|
||||
|
||||
it('getOwnedRatings', async () => {
|
||||
const ratings = [
|
||||
{
|
||||
entityRef: 'component:default/foo',
|
||||
entityTitle: 'Foo',
|
||||
ratings: { LIKE: 10 },
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/bar',
|
||||
entityTitle: 'Bar',
|
||||
ratings: { DISLIKE: 10 },
|
||||
},
|
||||
];
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/ratings?ownerRef=${encodeURIComponent(
|
||||
'group:default/team',
|
||||
)}`,
|
||||
(_, res, ctx) => res(ctx.json(ratings)),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await client.getOwnedRatings('group:default/team');
|
||||
expect(response).toEqual(ratings);
|
||||
});
|
||||
|
||||
it('recordRating', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
`${mockBaseUrl}/ratings/${encodeURIComponent(
|
||||
'component:default/service',
|
||||
)}`,
|
||||
(req, res) => {
|
||||
expect(req.body).toEqual({ rating: 'LIKE' });
|
||||
return res();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await client.recordRating('component:default/service', 'LIKE');
|
||||
});
|
||||
|
||||
it('getRatings', async () => {
|
||||
const ratings = [
|
||||
{ userRef: 'user:default/foo', rating: 'LIKE' },
|
||||
{ userRef: 'user:default/bar', rating: 'LIKE' },
|
||||
];
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/ratings/${encodeURIComponent(
|
||||
'component:default/service',
|
||||
)}`,
|
||||
(_, res, ctx) => res(ctx.json(ratings)),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await client.getRatings('component:default/service');
|
||||
expect(response).toEqual(ratings);
|
||||
});
|
||||
|
||||
it('recordResponse', async () => {
|
||||
expect.assertions(1);
|
||||
const response = {
|
||||
response: 'blah',
|
||||
comments: 'feedback',
|
||||
consent: false,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
`${mockBaseUrl}/responses/${encodeURIComponent(
|
||||
'component:default/service',
|
||||
)}`,
|
||||
(req, res) => {
|
||||
expect(req.body).toEqual(response);
|
||||
return res();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await client.recordResponse('component:default/service', response);
|
||||
});
|
||||
|
||||
it('getResponses', async () => {
|
||||
const responses = [
|
||||
{
|
||||
userRef: 'user:default/foo',
|
||||
response: 'asdf',
|
||||
comments: 'here is new feedback',
|
||||
consent: false,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/bar',
|
||||
response: 'noop',
|
||||
comments: 'here is different feedback',
|
||||
consent: true,
|
||||
},
|
||||
];
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`${mockBaseUrl}/responses/${encodeURIComponent(
|
||||
'component:default/service',
|
||||
)}`,
|
||||
(_, res, ctx) => res(ctx.json(responses)),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await client.getResponses('component:default/service');
|
||||
expect(response).toEqual(responses);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2023 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 { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import {
|
||||
EntityRatingsData,
|
||||
FeedbackResponse,
|
||||
Rating,
|
||||
} from '@backstage/plugin-entity-feedback-common';
|
||||
|
||||
import { EntityFeedbackApi } from './EntityFeedbackApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class EntityFeedbackClient implements EntityFeedbackApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly fetchApi: FetchApi;
|
||||
|
||||
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.fetchApi = options.fetchApi;
|
||||
}
|
||||
|
||||
async getAllRatings(): Promise<EntityRatingsData[]> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/ratings`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/ratings?ownerRef=${encodeURIComponent(ownerRef)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async recordRating(entityRef: string, rating: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/ratings/${encodeURIComponent(entityRef)}`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/ratings/${encodeURIComponent(entityRef)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async recordResponse(
|
||||
entityRef: string,
|
||||
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
|
||||
) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/responses/${encodeURIComponent(entityRef)}`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify(response),
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async getResponses(
|
||||
entityRef: string,
|
||||
): Promise<Omit<FeedbackResponse, 'entityRef'>[]> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/responses/${encodeURIComponent(entityRef)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './EntityFeedbackClient';
|
||||
export * from './EntityFeedbackApi';
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2023 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 { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
|
||||
import { FeedbackRatingsTable } from './FeedbackRatingsTable';
|
||||
|
||||
describe('FeedbackRatingsTable', () => {
|
||||
const sampleRatings = [
|
||||
{
|
||||
entityRef: 'component:default/foo',
|
||||
entityTitle: 'Foo Component',
|
||||
ratings: { 'Rating 1': 3, 'Rating 2': 1 },
|
||||
},
|
||||
{
|
||||
entityRef: 'system:default/bar',
|
||||
entityTitle: 'Bar Component',
|
||||
ratings: { 'Rating 1': 5 },
|
||||
},
|
||||
{
|
||||
entityRef: 'domain:default/hello-world',
|
||||
entityTitle: 'Hello World',
|
||||
ratings: { 'Rating 3': 5 },
|
||||
},
|
||||
];
|
||||
|
||||
const feedbackApi: Partial<EntityFeedbackApi> = {
|
||||
getAllRatings: jest.fn().mockImplementation(async () => sampleRatings),
|
||||
getOwnedRatings: jest.fn().mockImplementation(async () => sampleRatings),
|
||||
};
|
||||
|
||||
const sampleRatingValues = ['Rating 1', 'Rating 2'];
|
||||
|
||||
const render = async (props: any = {}) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider apis={[[entityFeedbackApiRef, feedbackApi]]}>
|
||||
<FeedbackRatingsTable {...props} ratingValues={sampleRatingValues} />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all ratings correctly', async () => {
|
||||
const rendered = await render({ allEntities: true });
|
||||
|
||||
expect(feedbackApi.getAllRatings).toHaveBeenCalled();
|
||||
expect(feedbackApi.getOwnedRatings).not.toHaveBeenCalled();
|
||||
|
||||
expect(rendered.getByText('Entity Ratings')).toBeInTheDocument();
|
||||
|
||||
// Columns
|
||||
expect(rendered.getByText('Entity')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Rating 1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Rating 2')).toBeInTheDocument();
|
||||
|
||||
// Rows
|
||||
expect(rendered.getByText('Foo Component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('3')).toBeInTheDocument();
|
||||
expect(rendered.getByText('1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Bar Component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('system')).toBeInTheDocument();
|
||||
expect(rendered.getByText('5')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.queryByText('Hello World')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders owned entity ratings correctly', async () => {
|
||||
const rendered = await render({
|
||||
ownerRef: 'group:default/test-team',
|
||||
title: 'Custom Title',
|
||||
});
|
||||
|
||||
expect(feedbackApi.getAllRatings).not.toHaveBeenCalled();
|
||||
expect(feedbackApi.getOwnedRatings).toHaveBeenCalledWith(
|
||||
'group:default/test-team',
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Custom Title')).toBeInTheDocument();
|
||||
|
||||
// Columns
|
||||
expect(rendered.getByText('Entity')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Rating 1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Rating 2')).toBeInTheDocument();
|
||||
|
||||
// Rows
|
||||
expect(rendered.getByText('Foo Component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('3')).toBeInTheDocument();
|
||||
expect(rendered.getByText('1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Bar Component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('system')).toBeInTheDocument();
|
||||
expect(rendered.getByText('5')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.queryByText('Hello World')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2023 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 { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { ErrorPanel, SubvalueCell, Table } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { EntityRefLink } from '@backstage/plugin-catalog-react';
|
||||
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
|
||||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { entityFeedbackApiRef } from '../../api';
|
||||
|
||||
interface FeedbackRatingsTableProps {
|
||||
allEntities?: boolean;
|
||||
ownerRef?: string;
|
||||
ratingValues: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FeedbackRatingsTable = (props: FeedbackRatingsTableProps) => {
|
||||
const {
|
||||
allEntities,
|
||||
ownerRef,
|
||||
ratingValues,
|
||||
title = 'Entity Ratings',
|
||||
} = props;
|
||||
const feedbackApi = useApi(entityFeedbackApiRef);
|
||||
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
value: ratings,
|
||||
} = useAsync(async () => {
|
||||
if (allEntities) {
|
||||
return feedbackApi.getAllRatings();
|
||||
}
|
||||
|
||||
if (!ownerRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feedbackApi.getOwnedRatings(ownerRef);
|
||||
}, [allEntities, feedbackApi, ownerRef]);
|
||||
|
||||
const columns = [
|
||||
{ title: 'Title', field: 'entityTitle', hidden: true, searchable: true },
|
||||
{
|
||||
title: 'Entity',
|
||||
field: 'entityRef',
|
||||
highlight: true,
|
||||
customSort: (a: EntityRatingsData, b: EntityRatingsData) => {
|
||||
const titleA = a.entityTitle ?? parseEntityRef(a.entityRef).name;
|
||||
const titleB = b.entityTitle ?? parseEntityRef(b.entityRef).name;
|
||||
return titleA.localeCompare(titleB);
|
||||
},
|
||||
render: (rating: EntityRatingsData) => {
|
||||
const compoundRef = parseEntityRef(rating.entityRef);
|
||||
return (
|
||||
<SubvalueCell
|
||||
value={
|
||||
<EntityRefLink
|
||||
entityRef={rating.entityRef}
|
||||
defaultKind={compoundRef.kind}
|
||||
title={rating.entityTitle}
|
||||
/>
|
||||
}
|
||||
subvalue={compoundRef.kind}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
...ratingValues.map(ratingVal => ({
|
||||
title: ratingVal,
|
||||
field: `ratings.${ratingVal}`,
|
||||
})),
|
||||
];
|
||||
|
||||
// Exclude entities that don't have applicable ratings
|
||||
const ratingsRows = ratings?.filter(r =>
|
||||
Object.keys(r.ratings).some(v => ratingValues.includes(v)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPanel
|
||||
defaultExpanded
|
||||
title="Failed to load feedback ratings"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<EntityRatingsData>
|
||||
columns={columns}
|
||||
data={ratingsRows ?? []}
|
||||
isLoading={loading}
|
||||
options={{
|
||||
emptyRowsWhenPaging: false,
|
||||
loadingType: 'linear',
|
||||
pageSize: 20,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
paging: true,
|
||||
showEmptyDataSourceMessage: !loading,
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './FeedbackRatingsTable';
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { getByRole, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
|
||||
import { FeedbackResponseDialog } from './FeedbackResponseDialog';
|
||||
|
||||
describe('FeedbackResponseDialog', () => {
|
||||
const testEntity: Partial<Entity> = {
|
||||
kind: 'component',
|
||||
metadata: { name: 'test', namespace: 'default' },
|
||||
};
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
const feedbackApi: Partial<EntityFeedbackApi> = {
|
||||
recordResponse: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const render = async (props: any = {}) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[entityFeedbackApiRef, feedbackApi],
|
||||
[errorApiRef, errorApi],
|
||||
]}
|
||||
>
|
||||
<FeedbackResponseDialog
|
||||
{...props}
|
||||
entity={testEntity}
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('allows customization of the dialog title', async () => {
|
||||
const rendered = await render();
|
||||
expect(
|
||||
rendered.getByText('Please provide feedback on what can be improved'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const customRendered = await render({ feedbackDialogTitle: 'Test Title' });
|
||||
expect(customRendered.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows customization of the reponse options', async () => {
|
||||
const rendered = await render();
|
||||
expect(rendered.getByText('Incorrect info')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Missing info')).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText('Other (please specify below)'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const customResponses = [
|
||||
{ id: 'foo', label: 'Foo option' },
|
||||
{ id: 'bar', label: 'Bar option' },
|
||||
];
|
||||
const customRendered = await render({
|
||||
feedbackDialogResponses: customResponses,
|
||||
});
|
||||
expect(customRendered.getByText('Foo option')).toBeInTheDocument();
|
||||
expect(customRendered.getByText('Bar option')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles saving user responses', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByRole('checkbox', { name: 'Incorrect info' }),
|
||||
);
|
||||
await userEvent.click(
|
||||
rendered.getByRole('checkbox', { name: 'Other (please specify below)' }),
|
||||
);
|
||||
await userEvent.type(
|
||||
getByRole(
|
||||
rendered.getByTestId('feedback-response-dialog-comments-input'),
|
||||
'textbox',
|
||||
),
|
||||
'test comments',
|
||||
);
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('feedback-response-dialog-submit-button'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(feedbackApi.recordResponse).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
{
|
||||
comments: 'test comments',
|
||||
consent: true,
|
||||
response: 'incorrect,other',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { entityFeedbackApiRef } from '../../api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface EntityFeedbackResponse {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const defaultFeedbackResponses: EntityFeedbackResponse[] = [
|
||||
{ id: 'incorrect', label: 'Incorrect info' },
|
||||
{ id: 'missing', label: 'Missing info' },
|
||||
{ id: 'other', label: 'Other (please specify below)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface FeedbackResponseDialogProps {
|
||||
entity: Entity;
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
contactConsent: {
|
||||
marginTop: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => {
|
||||
const {
|
||||
entity,
|
||||
feedbackDialogResponses = defaultFeedbackResponses,
|
||||
feedbackDialogTitle = 'Please provide feedback on what can be improved',
|
||||
open,
|
||||
onClose,
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const feedbackApi = useApi(entityFeedbackApiRef);
|
||||
const [responseSelections, setResponseSelections] = useState(
|
||||
Object.fromEntries(feedbackDialogResponses.map(r => [r.id, false])),
|
||||
);
|
||||
const [comments, setComments] = useState('');
|
||||
const [consent, setConsent] = useState(true);
|
||||
|
||||
const [{ loading: saving }, saveResponse] = useAsyncFn(async () => {
|
||||
try {
|
||||
await feedbackApi.recordResponse(stringifyEntityRef(entity), {
|
||||
comments,
|
||||
consent,
|
||||
response: Object.keys(responseSelections)
|
||||
.filter(id => responseSelections[id])
|
||||
.join(','),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
errorApi.post(e as ErrorApiError);
|
||||
}
|
||||
}, [comments, consent, entity, feedbackApi, onClose, responseSelections]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => !saving && onClose()}>
|
||||
{saving && <Progress />}
|
||||
<DialogTitle>{feedbackDialogTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">Choose all that applies</FormLabel>
|
||||
<FormGroup>
|
||||
{feedbackDialogResponses.map(response => (
|
||||
<FormControlLabel
|
||||
key={response.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={responseSelections[response.id]}
|
||||
disabled={saving}
|
||||
name={response.id}
|
||||
onChange={e =>
|
||||
setResponseSelections({
|
||||
...responseSelections,
|
||||
[e.target.name]: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={response.label}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
data-testid="feedback-response-dialog-comments-input"
|
||||
disabled={saving}
|
||||
label="Additional comments"
|
||||
multiline
|
||||
minRows={2}
|
||||
onChange={e => setComments(e.target.value)}
|
||||
variant="outlined"
|
||||
value={comments}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography className={classes.contactConsent}>
|
||||
Can we reach out to you for more info?
|
||||
<Grid component="label" container alignItems="center" spacing={1}>
|
||||
<Grid item>No</Grid>
|
||||
<Grid item>
|
||||
<Switch
|
||||
checked={consent}
|
||||
disabled={saving}
|
||||
onChange={e => setConsent(e.target.checked)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>Yes</Grid>
|
||||
</Grid>
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" disabled={saving} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
data-testid="feedback-response-dialog-submit-button"
|
||||
disabled={saving}
|
||||
onClick={saveResponse}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './FeedbackResponseDialog';
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2023 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, TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
|
||||
import { FeedbackResponseTable } from './FeedbackResponseTable';
|
||||
|
||||
describe('FeedbackResponseTable', () => {
|
||||
const sampleResponses = [
|
||||
{
|
||||
userRef: 'user:default/foo',
|
||||
consent: true,
|
||||
response: 'resp1,resp2',
|
||||
comments: 'test comment 1',
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/bar',
|
||||
consent: false,
|
||||
response: 'resp3,resp4',
|
||||
comments: 'test comment 2',
|
||||
},
|
||||
];
|
||||
|
||||
const feedbackApi: Partial<EntityFeedbackApi> = {
|
||||
getResponses: jest.fn().mockImplementation(async () => sampleResponses),
|
||||
};
|
||||
|
||||
const render = async (props: any = {}) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider apis={[[entityFeedbackApiRef, feedbackApi]]}>
|
||||
<FeedbackResponseTable {...props} entityRef="component:default/test" />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all responses correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(feedbackApi.getResponses).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Entity Responses')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
expect(rendered.getByText('resp1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('resp2')).toBeInTheDocument();
|
||||
expect(rendered.getByText('test comment 1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('bar')).toBeInTheDocument();
|
||||
expect(rendered.getByText('resp3')).toBeInTheDocument();
|
||||
expect(rendered.getByText('resp4')).toBeInTheDocument();
|
||||
expect(rendered.getByText('test comment 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom title correctly', async () => {
|
||||
const rendered = await render({ title: 'Custom Title' });
|
||||
expect(rendered.getByText('Custom Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2023 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 { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { ErrorPanel, Table } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
|
||||
import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Chip, makeStyles } from '@material-ui/core';
|
||||
import CheckIcon from '@material-ui/icons/Check';
|
||||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { entityFeedbackApiRef } from '../../api';
|
||||
|
||||
type ResponseRow = Omit<FeedbackResponse, 'entityRef'>;
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
consentCheck: {
|
||||
color: theme.palette.status.ok,
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface FeedbackResponseTableProps {
|
||||
entityRef: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FeedbackResponseTable = (props: FeedbackResponseTableProps) => {
|
||||
const { entityRef, title = 'Entity Responses' } = props;
|
||||
const classes = useStyles();
|
||||
const feedbackApi = useApi(entityFeedbackApiRef);
|
||||
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
value: responses,
|
||||
} = useAsync(async () => {
|
||||
if (!entityRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feedbackApi.getResponses(entityRef);
|
||||
}, [entityRef, feedbackApi]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'User',
|
||||
field: 'userRef',
|
||||
width: '15%',
|
||||
render: (response: ResponseRow) =>
|
||||
humanizeEntityRef(parseEntityRef(response.userRef), {
|
||||
defaultKind: 'user',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'OK to contact?',
|
||||
field: 'consent',
|
||||
width: '10%',
|
||||
render: (response: ResponseRow) =>
|
||||
response.consent ? <CheckIcon className={classes.consentCheck} /> : '',
|
||||
},
|
||||
{
|
||||
title: 'Responses',
|
||||
field: 'response',
|
||||
width: '35%',
|
||||
render: (response: ResponseRow) => (
|
||||
<>
|
||||
{response.response?.split(',').map(res => (
|
||||
<Chip key={res} size="small" label={res} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ title: 'Comments', field: 'comments', width: '40%' },
|
||||
];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPanel
|
||||
defaultExpanded
|
||||
title="Failed to load feedback responses"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<ResponseRow>
|
||||
columns={columns}
|
||||
data={(responses ?? []) as ResponseRow[]}
|
||||
isLoading={loading}
|
||||
options={{
|
||||
emptyRowsWhenPaging: false,
|
||||
loadingType: 'linear',
|
||||
pageSize: 20,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
paging: true,
|
||||
showEmptyDataSourceMessage: !loading,
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './FeedbackResponseTable';
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { AsyncEntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
|
||||
import { FeedbackRatings, LikeDislikeButtons } from './LikeDislikeButtons';
|
||||
|
||||
jest.mock('../FeedbackResponseDialog', () => ({
|
||||
FeedbackResponseDialog: ({ open }: { open: boolean }) => {
|
||||
return <>{open && <span>dialog is open</span>}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LikeDislikeButtons', () => {
|
||||
const sampleRatings = [
|
||||
{
|
||||
userRef: 'user:default/me',
|
||||
rating: FeedbackRatings.like,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/someone',
|
||||
rating: FeedbackRatings.dislike,
|
||||
},
|
||||
];
|
||||
|
||||
const testEntity = {
|
||||
kind: 'component',
|
||||
metadata: { name: 'test', namespace: 'default' },
|
||||
} as Entity;
|
||||
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
|
||||
const feedbackApi: Partial<EntityFeedbackApi> = {
|
||||
getRatings: jest.fn().mockImplementation(async () => sampleRatings),
|
||||
recordRating: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const identityApi: Partial<IdentityApi> = {
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/me',
|
||||
ownershipEntityRefs: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const render = async (props: any = {}) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[entityFeedbackApiRef, feedbackApi],
|
||||
[errorApiRef, errorApi],
|
||||
[identityApiRef, identityApi],
|
||||
]}
|
||||
>
|
||||
<AsyncEntityProvider loading={false} entity={testEntity}>
|
||||
<LikeDislikeButtons {...props} />
|
||||
</AsyncEntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads the previous rating if it exists', async () => {
|
||||
await render();
|
||||
expect(feedbackApi.getRatings).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a rating correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-dislike-button'),
|
||||
);
|
||||
expect(feedbackApi.recordRating).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
FeedbackRatings.dislike,
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
await userEvent.click(rendered.getByTestId('entity-feedback-like-button'));
|
||||
expect(feedbackApi.recordRating).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
FeedbackRatings.like,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes an existing rating correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
await userEvent.click(rendered.getByTestId('entity-feedback-like-button'));
|
||||
expect(feedbackApi.recordRating).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
FeedbackRatings.neutral,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a response dialog if dislike is selected', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-dislike-button'),
|
||||
);
|
||||
expect(rendered.getByText('dialog is open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not open a response dialog on dislike if configured not to', async () => {
|
||||
const rendered = await render({ requestResponse: false });
|
||||
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-dislike-button'),
|
||||
);
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import {
|
||||
ErrorApiError,
|
||||
errorApiRef,
|
||||
identityApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { useAsyncEntity } from '@backstage/plugin-catalog-react';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import ThumbDownIcon from '@material-ui/icons/ThumbDown';
|
||||
import ThumbUpIcon from '@material-ui/icons/ThumbUp';
|
||||
import ThumbDownOutlinedIcon from '@material-ui/icons/ThumbDownOutlined';
|
||||
import ThumbUpOutlinedIcon from '@material-ui/icons/ThumbUpOutlined';
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { entityFeedbackApiRef } from '../../api';
|
||||
import {
|
||||
EntityFeedbackResponse,
|
||||
FeedbackResponseDialog,
|
||||
} from '../FeedbackResponseDialog';
|
||||
|
||||
export enum FeedbackRatings {
|
||||
like = 'LIKE',
|
||||
dislike = 'DISLIKE',
|
||||
neutral = 'NEUTRAL',
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface LikeDislikeButtonsProps {
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
requestResponse?: boolean;
|
||||
}
|
||||
|
||||
export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => {
|
||||
const {
|
||||
feedbackDialogResponses,
|
||||
feedbackDialogTitle,
|
||||
requestResponse = true,
|
||||
} = props;
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const feedbackApi = useApi(entityFeedbackApiRef);
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const [rating, setRating] = useState<FeedbackRatings>(
|
||||
FeedbackRatings.neutral,
|
||||
);
|
||||
const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false);
|
||||
const { entity, loading: loadingEntity } = useAsyncEntity();
|
||||
|
||||
const { loading: loadingFeedback } = useAsync(async () => {
|
||||
// Wait until entity is loaded
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await identityApi.getBackstageIdentity();
|
||||
const prevFeedback = await feedbackApi.getRatings(
|
||||
stringifyEntityRef(entity),
|
||||
);
|
||||
setRating(
|
||||
(prevFeedback.find(r => r.userRef === identity.userEntityRef)?.rating ??
|
||||
rating) as FeedbackRatings,
|
||||
);
|
||||
} catch (e) {
|
||||
errorApi.post(e as ErrorApiError);
|
||||
}
|
||||
}, [entity, feedbackApi, setRating]);
|
||||
|
||||
const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn(
|
||||
async (feedback: FeedbackRatings) => {
|
||||
try {
|
||||
await feedbackApi.recordRating(stringifyEntityRef(entity!), feedback);
|
||||
setRating(feedback);
|
||||
} catch (e) {
|
||||
errorApi.post(e as ErrorApiError);
|
||||
}
|
||||
},
|
||||
[entity, feedbackApi, setRating],
|
||||
);
|
||||
|
||||
const applyRating = useCallback(
|
||||
(feedback: FeedbackRatings) => {
|
||||
// Clear rating if feedback is same as current
|
||||
if (feedback === rating) {
|
||||
saveFeedback(FeedbackRatings.neutral);
|
||||
return;
|
||||
}
|
||||
|
||||
saveFeedback(feedback);
|
||||
if (feedback === FeedbackRatings.dislike && requestResponse) {
|
||||
setOpenFeedbackDialog(true);
|
||||
}
|
||||
},
|
||||
[rating, requestResponse, saveFeedback, setOpenFeedbackDialog],
|
||||
);
|
||||
|
||||
if (loadingEntity || loadingFeedback || savingFeedback) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
data-testid="entity-feedback-like-button"
|
||||
onClick={() => applyRating(FeedbackRatings.like)}
|
||||
>
|
||||
{rating === FeedbackRatings.like ? (
|
||||
<ThumbUpIcon fontSize="small" />
|
||||
) : (
|
||||
<ThumbUpOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid="entity-feedback-dislike-button"
|
||||
onClick={() => applyRating(FeedbackRatings.dislike)}
|
||||
>
|
||||
{rating === FeedbackRatings.dislike ? (
|
||||
<ThumbDownIcon fontSize="small" />
|
||||
) : (
|
||||
<ThumbDownOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
<FeedbackResponseDialog
|
||||
entity={entity!}
|
||||
open={openFeedbackDialog}
|
||||
onClose={() => setOpenFeedbackDialog(false)}
|
||||
feedbackDialogResponses={feedbackDialogResponses}
|
||||
feedbackDialogTitle={feedbackDialogTitle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './LikeDislikeButtons';
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2023 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 } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
|
||||
import { FeedbackRatings } from '../LikeDislikeButtons';
|
||||
import { LikeDislikeRatingsTable } from './LikeDislikeRatingsTable';
|
||||
|
||||
jest.mock('../FeedbackRatingsTable', () => ({
|
||||
FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => {
|
||||
return (
|
||||
<span>
|
||||
{ratingValues.map(v => (
|
||||
<span key={v}>{v}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LikeDislikeRatingsTable', () => {
|
||||
it('renders like-dislike ratings correctly', async () => {
|
||||
const rendered = await renderInTestApp(<LikeDislikeRatingsTable />);
|
||||
expect(rendered.getByText(FeedbackRatings.like)).toBeInTheDocument();
|
||||
expect(rendered.queryByText(FeedbackRatings.neutral)).toBeNull();
|
||||
expect(rendered.getByText(FeedbackRatings.dislike)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 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 React from 'react';
|
||||
|
||||
import { FeedbackRatingsTable } from '../FeedbackRatingsTable';
|
||||
import { FeedbackRatings } from '../LikeDislikeButtons';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface LikeDislikeRatingsTableProps {
|
||||
allEntities?: boolean;
|
||||
ownerRef?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const LikeDislikeRatingsTable = (
|
||||
props: LikeDislikeRatingsTableProps,
|
||||
) => {
|
||||
const { allEntities, ownerRef, title } = props;
|
||||
|
||||
return (
|
||||
<FeedbackRatingsTable
|
||||
allEntities={allEntities}
|
||||
ownerRef={ownerRef}
|
||||
ratingValues={Object.values(FeedbackRatings).filter(
|
||||
r => r !== FeedbackRatings.neutral,
|
||||
)}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './LikeDislikeRatingsTable';
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { AsyncEntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
|
||||
import { FeedbackRatings, StarredRatingButtons } from './StarredRatingButtons';
|
||||
|
||||
jest.mock('../FeedbackResponseDialog', () => ({
|
||||
FeedbackResponseDialog: ({ open }: { open: boolean }) => {
|
||||
return <>{open && <span>dialog is open</span>}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('StarredRatingButtons', () => {
|
||||
const sampleRatings = [
|
||||
{
|
||||
userRef: 'user:default/me',
|
||||
rating: FeedbackRatings.two,
|
||||
},
|
||||
{
|
||||
userRef: 'user:default/someone',
|
||||
rating: FeedbackRatings.five,
|
||||
},
|
||||
];
|
||||
|
||||
const testEntity = {
|
||||
kind: 'component',
|
||||
metadata: { name: 'test', namespace: 'default' },
|
||||
} as Entity;
|
||||
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
|
||||
const feedbackApi: Partial<EntityFeedbackApi> = {
|
||||
getRatings: jest.fn().mockImplementation(async () => sampleRatings),
|
||||
recordRating: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const identityApi: Partial<IdentityApi> = {
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/me',
|
||||
ownershipEntityRefs: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const render = async (props: any = {}) =>
|
||||
renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[entityFeedbackApiRef, feedbackApi],
|
||||
[errorApiRef, errorApi],
|
||||
[identityApiRef, identityApi],
|
||||
]}
|
||||
>
|
||||
<AsyncEntityProvider loading={false} entity={testEntity}>
|
||||
<StarredRatingButtons {...props} />
|
||||
</AsyncEntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads the previous rating if it exists', async () => {
|
||||
await render();
|
||||
expect(feedbackApi.getRatings).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies a rating correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-3'),
|
||||
);
|
||||
expect(feedbackApi.recordRating).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
FeedbackRatings.three.toString(),
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-5'),
|
||||
);
|
||||
expect(feedbackApi.recordRating).toHaveBeenCalledWith(
|
||||
'component:default/test',
|
||||
FeedbackRatings.five.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an existing rating correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-2'),
|
||||
);
|
||||
expect(feedbackApi.recordRating).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a response dialog if a rating under the threshold is selected', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-1'),
|
||||
);
|
||||
expect(rendered.getByText('dialog is open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a response dialog if a rating under a custom threshold is selected', async () => {
|
||||
const rendered = await render({ requestResponseThreshold: 4 });
|
||||
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-3'),
|
||||
);
|
||||
expect(rendered.getByText('dialog is open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not open a response dialog if configured not to', async () => {
|
||||
const rendered = await render({ requestResponse: false });
|
||||
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
|
||||
await userEvent.click(
|
||||
rendered.getByTestId('entity-feedback-star-button-1'),
|
||||
);
|
||||
expect(rendered.queryByText('dialog is open')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import {
|
||||
ErrorApiError,
|
||||
errorApiRef,
|
||||
identityApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { useAsyncEntity } from '@backstage/plugin-catalog-react';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import StarOutlineIcon from '@material-ui/icons/StarOutline';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { entityFeedbackApiRef } from '../../api';
|
||||
import {
|
||||
EntityFeedbackResponse,
|
||||
FeedbackResponseDialog,
|
||||
} from '../FeedbackResponseDialog';
|
||||
|
||||
export enum FeedbackRatings {
|
||||
one = 1,
|
||||
two = 2,
|
||||
three = 3,
|
||||
four = 4,
|
||||
five = 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface StarredRatingButtonsProps {
|
||||
feedbackDialogResponses?: EntityFeedbackResponse[];
|
||||
feedbackDialogTitle?: ReactNode;
|
||||
requestResponse?: boolean;
|
||||
requestResponseThreshold?: number;
|
||||
}
|
||||
|
||||
export const StarredRatingButtons = (props: StarredRatingButtonsProps) => {
|
||||
const {
|
||||
feedbackDialogResponses,
|
||||
feedbackDialogTitle,
|
||||
requestResponse = true,
|
||||
requestResponseThreshold = FeedbackRatings.two,
|
||||
} = props;
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const feedbackApi = useApi(entityFeedbackApiRef);
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const [rating, setRating] = useState<FeedbackRatings>();
|
||||
const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false);
|
||||
const { entity, loading: loadingEntity } = useAsyncEntity();
|
||||
|
||||
const { loading: loadingFeedback } = useAsync(async () => {
|
||||
// Wait until entity is loaded
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await identityApi.getBackstageIdentity();
|
||||
const prevFeedback = await feedbackApi.getRatings(
|
||||
stringifyEntityRef(entity),
|
||||
);
|
||||
|
||||
const prevRating = prevFeedback.find(
|
||||
r => r.userRef === identity.userEntityRef,
|
||||
)?.rating;
|
||||
if (prevRating) {
|
||||
setRating(parseInt(prevRating, 10));
|
||||
}
|
||||
} catch (e) {
|
||||
errorApi.post(e as ErrorApiError);
|
||||
}
|
||||
}, [entity, feedbackApi, setRating]);
|
||||
|
||||
const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn(
|
||||
async (feedback: FeedbackRatings) => {
|
||||
try {
|
||||
await feedbackApi.recordRating(
|
||||
stringifyEntityRef(entity!),
|
||||
feedback.toString(),
|
||||
);
|
||||
setRating(feedback);
|
||||
} catch (e) {
|
||||
errorApi.post(e as ErrorApiError);
|
||||
}
|
||||
},
|
||||
[entity, feedbackApi, setRating],
|
||||
);
|
||||
|
||||
const applyRating = useCallback(
|
||||
(feedback: FeedbackRatings) => {
|
||||
// Ignore rating if feedback is same as current
|
||||
if (feedback === rating) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveFeedback(feedback);
|
||||
if (feedback <= requestResponseThreshold && requestResponse) {
|
||||
setOpenFeedbackDialog(true);
|
||||
}
|
||||
},
|
||||
[
|
||||
rating,
|
||||
requestResponse,
|
||||
requestResponseThreshold,
|
||||
saveFeedback,
|
||||
setOpenFeedbackDialog,
|
||||
],
|
||||
);
|
||||
|
||||
if (loadingEntity || loadingFeedback || savingFeedback) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(FeedbackRatings)
|
||||
.filter(o => typeof o === 'number')
|
||||
.map(starRating => (
|
||||
<IconButton
|
||||
key={starRating}
|
||||
data-testid={`entity-feedback-star-button-${starRating}`}
|
||||
onClick={() => applyRating(starRating as FeedbackRatings)}
|
||||
>
|
||||
{rating && rating >= starRating ? (
|
||||
<StarIcon fontSize="small" />
|
||||
) : (
|
||||
<StarOutlineIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
))}
|
||||
<FeedbackResponseDialog
|
||||
entity={entity!}
|
||||
open={openFeedbackDialog}
|
||||
onClose={() => setOpenFeedbackDialog(false)}
|
||||
feedbackDialogResponses={feedbackDialogResponses}
|
||||
feedbackDialogTitle={feedbackDialogTitle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './StarredRatingButtons';
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 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 } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
|
||||
import { FeedbackRatings } from '../StarredRatingButtons';
|
||||
import { StarredRatingsTable } from './StarredRatingsTable';
|
||||
|
||||
jest.mock('../FeedbackRatingsTable', () => ({
|
||||
FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => {
|
||||
return (
|
||||
<span>
|
||||
{ratingValues.map(v => (
|
||||
<span key={v}>{v}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('StarredRatingsTable', () => {
|
||||
it('renders starred ratings correctly', async () => {
|
||||
const rendered = await renderInTestApp(<StarredRatingsTable />);
|
||||
expect(rendered.getByText(FeedbackRatings.one)).toBeInTheDocument();
|
||||
expect(rendered.getByText(FeedbackRatings.two)).toBeInTheDocument();
|
||||
expect(rendered.getByText(FeedbackRatings.three)).toBeInTheDocument();
|
||||
expect(rendered.getByText(FeedbackRatings.four)).toBeInTheDocument();
|
||||
expect(rendered.getByText(FeedbackRatings.five)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 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 React from 'react';
|
||||
|
||||
import { FeedbackRatingsTable } from '../FeedbackRatingsTable';
|
||||
import { FeedbackRatings } from '../StarredRatingButtons';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface StarredRatingsTableProps {
|
||||
allEntities?: boolean;
|
||||
ownerRef?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const StarredRatingsTable = (props: StarredRatingsTableProps) => {
|
||||
const { allEntities, ownerRef, title } = props;
|
||||
|
||||
return (
|
||||
<FeedbackRatingsTable
|
||||
allEntities={allEntities}
|
||||
ownerRef={ownerRef}
|
||||
ratingValues={Object.values(FeedbackRatings)
|
||||
.filter(o => typeof o === 'number')
|
||||
.map(r => r.toString())}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from './StarredRatingsTable';
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export type {
|
||||
EntityFeedbackResponse,
|
||||
FeedbackResponseDialogProps,
|
||||
} from './FeedbackResponseDialog';
|
||||
export type { FeedbackResponseTableProps } from './FeedbackResponseTable';
|
||||
export type { LikeDislikeButtonsProps } from './LikeDislikeButtons';
|
||||
export type { LikeDislikeRatingsTableProps } from './LikeDislikeRatingsTable';
|
||||
export type { StarredRatingButtonsProps } from './StarredRatingButtons';
|
||||
export type { StarredRatingsTableProps } from './StarredRatingsTable';
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Entity Feedback frontend plugin
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export type {
|
||||
LikeDislikeButtonsProps,
|
||||
LikeDislikeRatingsTableProps,
|
||||
EntityFeedbackResponse,
|
||||
FeedbackResponseDialogProps,
|
||||
FeedbackResponseTableProps,
|
||||
StarredRatingButtonsProps,
|
||||
StarredRatingsTableProps,
|
||||
} from './components';
|
||||
export * from './plugin';
|
||||
export * from './api';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2023 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 { entityFeedbackPlugin } from './plugin';
|
||||
|
||||
describe('entity-feedback', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(entityFeedbackPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
createApiFactory,
|
||||
createComponentExtension,
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { useAsyncEntity } from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
|
||||
import { entityFeedbackApiRef, EntityFeedbackClient } from './api';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const entityFeedbackPlugin = createPlugin({
|
||||
id: 'entity-feedback',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: entityFeedbackApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
fetchApi: fetchApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new EntityFeedbackClient({ discoveryApi, fetchApi }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const LikeDislikeButtons = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'LikeDislikeButtons',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/LikeDislikeButtons').then(
|
||||
m => m.LikeDislikeButtons,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const StarredRatingButtons = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'StarredRatingButtons',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/StarredRatingButtons').then(
|
||||
m => m.StarredRatingButtons,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const FeedbackResponseDialog = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'FeedbackResponseDialog',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/FeedbackResponseDialog').then(
|
||||
m => m.FeedbackResponseDialog,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const EntityFeedbackResponseContent = entityFeedbackPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityFeedbackResponseContent',
|
||||
mountPoint: rootRouteRef,
|
||||
component: () =>
|
||||
import('./components/FeedbackResponseTable').then(
|
||||
({ FeedbackResponseTable }) => {
|
||||
return () => {
|
||||
const { entity } = useAsyncEntity();
|
||||
return (
|
||||
<FeedbackResponseTable
|
||||
entityRef={entity ? stringifyEntityRef(entity) : ''}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const FeedbackResponseTable = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'FeedbackResponseTable',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/FeedbackResponseTable').then(
|
||||
m => m.FeedbackResponseTable,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const EntityLikeDislikeRatingsCard = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityLikeDislikeRatingsCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/LikeDislikeRatingsTable').then(
|
||||
({ LikeDislikeRatingsTable }) => {
|
||||
return () => {
|
||||
const { entity } = useAsyncEntity();
|
||||
return (
|
||||
<LikeDislikeRatingsTable
|
||||
ownerRef={entity ? stringifyEntityRef(entity) : ''}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const LikeDislikeRatingsTable = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'LikeDislikeRatingsTable',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/LikeDislikeRatingsTable').then(
|
||||
m => m.LikeDislikeRatingsTable,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const EntityStarredRatingsCard = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityStarredRatingsCard',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/StarredRatingsTable').then(
|
||||
({ StarredRatingsTable }) => {
|
||||
return () => {
|
||||
const { entity } = useAsyncEntity();
|
||||
return (
|
||||
<StarredRatingsTable
|
||||
ownerRef={entity ? stringifyEntityRef(entity) : ''}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const StarredRatingsTable = entityFeedbackPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'StarredRatingsTable',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/StarredRatingsTable').then(
|
||||
m => m.StarredRatingsTable,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'entity-feedback',
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 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 '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -42,6 +42,7 @@ const mockOctokit = {
|
||||
},
|
||||
teams: {
|
||||
addOrUpdateRepoPermissionsInOrg: jest.fn(),
|
||||
getByName: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,6 +97,13 @@ describe('github:repo:create', () => {
|
||||
data: { type: 'Organization' },
|
||||
});
|
||||
|
||||
mockOctokit.rest.teams.getByName.mockResolvedValue({
|
||||
data: {
|
||||
name: 'blam',
|
||||
id: 42,
|
||||
},
|
||||
});
|
||||
|
||||
mockOctokit.rest.repos.createInOrg.mockResolvedValue({ data: {} });
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||