diff --git a/.changeset/famous-squids-wonder.md b/.changeset/famous-squids-wonder.md new file mode 100644 index 0000000000..5b027e9a63 --- /dev/null +++ b/.changeset/famous-squids-wonder.md @@ -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. diff --git a/.changeset/gorgeous-llamas-mate.md b/.changeset/gorgeous-llamas-mate.md new file mode 100644 index 0000000000..a2553875e9 --- /dev/null +++ b/.changeset/gorgeous-llamas-mate.md @@ -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! diff --git a/.changeset/itchy-rings-rule.md b/.changeset/itchy-rings-rule.md new file mode 100644 index 0000000000..3e8b9e8b2b --- /dev/null +++ b/.changeset/itchy-rings-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixed an issue where entities sometimes were not properly deleted during a full mutation. diff --git a/.changeset/olive-ads-peel.md b/.changeset/olive-ads-peel.md new file mode 100644 index 0000000000..7a7c73ddb7 --- /dev/null +++ b/.changeset/olive-ads-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Provide better error messaging when GitHub fails due to missing team definitions diff --git a/.changeset/pretty-jars-peel.md b/.changeset/pretty-jars-peel.md new file mode 100644 index 0000000000..639d4565ed --- /dev/null +++ b/.changeset/pretty-jars-peel.md @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1c6171cb01..10f1ac188c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index 36859dab45..b03201893c 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -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 diff --git a/docs/auth/bitbucketServer/provider.md b/docs/auth/bitbucketServer/provider.md new file mode 100644 index 0000000000..96c7db263e --- /dev/null +++ b/docs/auth/bitbucketServer/provider.md @@ -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. diff --git a/docs/auth/index.md b/docs/auth/index.md index fa185c0c23..8e15830aa8 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -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) diff --git a/microsite/data/plugins/entity-feedback.yaml b/microsite/data/plugins/entity-feedback.yaml new file mode 100644 index 0000000000..c40d0450ca --- /dev/null +++ b/microsite/data/plugins/entity-feedback.yaml @@ -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' diff --git a/microsite/static/img/entity-feedback-logo.png b/microsite/static/img/entity-feedback-logo.png new file mode 100644 index 0000000000..e172b260a0 Binary files /dev/null and b/microsite/static/img/entity-feedback-logo.png differ diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 3f5cfc1c58..d3ebddfb84 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -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: { diff --git a/packages/app/package.json b/packages/app/package.json index 80ee11ea15..afb1738207 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3ab0e83238..36a8b7553f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -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 = ( + + + + + + {cicdCard} @@ -513,6 +524,10 @@ const serviceEntityPage = ( > + + + + ); @@ -592,6 +607,10 @@ const websiteEntityPage = ( + + + + ); @@ -608,6 +627,10 @@ const defaultEntityPage = ( + + + + ); @@ -644,6 +667,11 @@ const apiPage = ( + + + + + @@ -656,6 +684,10 @@ const apiPage = ( + + + + ); @@ -673,6 +705,9 @@ const userPage = ( entityFilterKind={customEntityFilterKind} /> + + + @@ -695,6 +730,9 @@ const groupPage = ( + + + @@ -720,6 +758,11 @@ const systemPage = ( + + + + + @@ -748,6 +791,9 @@ const systemPage = ( unidirectional={false} /> + + + ); @@ -765,8 +811,16 @@ const domainPage = ( + + + + + + + + ); diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 5f8c0faf47..66f1460210 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -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, + }, ]; diff --git a/packages/backend/package.json b/packages/backend/package.json index 3678c1f9ae..34a7498927 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b68b162e52..495dc435c1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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()); diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 8beda6f3fb..0d92315f92 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -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 `` diff --git a/packages/backend/src/plugins/entityFeedback.ts b/packages/backend/src/plugins/entityFeedback.ts new file mode 100644 index 0000000000..c37c3c9a97 --- /dev/null +++ b/packages/backend/src/plugins/entityFeedback.ts @@ -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 { + return createRouter({ + database: env.database, + discovery: env.discovery, + identity: env.identity, + logger: env.logger, + }); +} diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 133ac22e5b..37f7a98de0 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -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; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; + // @public export type BitbucketSession = { providerInfo: { diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts new file mode 100644 index 0000000000..32e7755a00 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts @@ -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) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts new file mode 100644 index 0000000000..dc50eff6ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts @@ -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, + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts new file mode 100644 index 0000000000..5fcb8f72f2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts @@ -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'; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts new file mode 100644 index 0000000000..1041aa8110 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts @@ -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; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index c4cf520db5..b54e0424da 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -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'; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index dc7aa9eb5f..91f05921bf 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -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'; diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index 163ae74806..43597639b6 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -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. * diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index f6b19189cd..63aa060875 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -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; }>; }>; + bitbucketServer: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + emailMatchingUserEntityProfileEmail: () => SignInResolver; + }>; + }>; cfAccess: Readonly<{ create: (options: { authHandler?: AuthHandler | undefined; diff --git a/plugins/auth-backend/src/providers/bitbucketServer/index.ts b/plugins/auth-backend/src/providers/bitbucketServer/index.ts new file mode 100644 index 0000000000..cef12cd50d --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/index.ts @@ -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'; diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts new file mode 100644 index 0000000000..fc48cdbe8b --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts @@ -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); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts new file mode 100644 index 0000000000..fb8dd3d5b8 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts @@ -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; + signInResolver?: SignInResolver; + resolverContext: AuthResolverContext; +}; + +export class BitbucketServerAuthProvider implements OAuthHandlers { + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + 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 { + 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 { + // 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 { + // 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; + + /** + * 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; + }; + }) { + 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 = + 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 => commonByEmailResolver, + }, +}); diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 67d3a62990..a922065fd8 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -19,6 +19,7 @@ export type { BitbucketOAuthResult, BitbucketPassportProfile, } from './bitbucket'; +export type { BitbucketServerOAuthResult } from './bitbucketServer'; export type { CloudflareAccessClaims, CloudflareAccessGroup, diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index b2795c25f0..aa630b7481 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -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(), }; diff --git a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts index b873f6a985..827ab58ebe 100644 --- a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts @@ -105,6 +105,7 @@ describe('DefaultCatalogDatabase', () => { 'location:default/root-2', ]); }, + 60_000, ); }); }); diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts index 140925b6fd..09bec187fa 100644 --- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts @@ -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('refresh_state').select(); + expect(state).toEqual([]); + }, + 60_000, + ); }); }); diff --git a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts new file mode 100644 index 0000000000..8ab94df64a --- /dev/null +++ b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts @@ -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 { + 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('refresh_state_references').insert( + refs, + ); + } + + async function insertEntity(knex: Knex, ...entityRefs: string[]) { + for (const ref of entityRefs) { + await knex('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('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, + ); +}); diff --git a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts index 3d642cbf3a..33da37bc55 100644 --- a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts +++ b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts @@ -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 { 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('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 diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts index 34e89fad29..592b1dda21 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -35,6 +35,10 @@ const entity = { }; const location: Record = { + 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); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index 677649d4b2..21b2d05a46 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -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; } diff --git a/plugins/entity-feedback-backend/.eslintrc.js b/plugins/entity-feedback-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback-backend/README.md b/plugins/entity-feedback-backend/README.md new file mode 100644 index 0000000000..ef180dbae5 --- /dev/null +++ b/plugins/entity-feedback-backend/README.md @@ -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 { + 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()); + +``` diff --git a/plugins/entity-feedback-backend/api-report.md b/plugins/entity-feedback-backend/api-report.md new file mode 100644 index 0000000000..3909ac3056 --- /dev/null +++ b/plugins/entity-feedback-backend/api-report.md @@ -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; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: Logger; +} +``` diff --git a/plugins/entity-feedback-backend/migrations/20230109124329_init.js b/plugins/entity-feedback-backend/migrations/20230109124329_init.js new file mode 100644 index 0000000000..42a48d264c --- /dev/null +++ b/plugins/entity-feedback-backend/migrations/20230109124329_init.js @@ -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'); +}; diff --git a/plugins/entity-feedback-backend/package.json b/plugins/entity-feedback-backend/package.json new file mode 100644 index 0000000000..7fbc7b9f7e --- /dev/null +++ b/plugins/entity-feedback-backend/package.json @@ -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}" + ] +} diff --git a/plugins/entity-feedback-backend/src/index.ts b/plugins/entity-feedback-backend/src/index.ts new file mode 100644 index 0000000000..b3c4139eed --- /dev/null +++ b/plugins/entity-feedback-backend/src/index.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'; diff --git a/plugins/entity-feedback-backend/src/run.ts b/plugins/entity-feedback-backend/src/run.ts new file mode 100644 index 0000000000..7344f24c17 --- /dev/null +++ b/plugins/entity-feedback-backend/src/run.ts @@ -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); +}); diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts new file mode 100644 index 0000000000..c6cde95b3c --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts @@ -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, + ); +}); diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts new file mode 100644 index 0000000000..9b81cd94bb --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts @@ -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 { + 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 { + return (await this.database('ratings').distinct('entity_ref')).map( + ({ entity_ref }) => entity_ref, + ); + } + + async getRatingsAggregates( + entityRefs: string[], + ): Promise { + 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[]> { + 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[]> { + 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), + })); + } +} diff --git a/plugins/entity-feedback-backend/src/service/router.test.ts b/plugins/entity-feedback-backend/src/service/router.test.ts new file mode 100644 index 0000000000..74fae8248b --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/router.test.ts @@ -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 = { + 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'), + ); + }); + }); +}); diff --git a/plugins/entity-feedback-backend/src/service/router.ts b/plugins/entity-feedback-backend/src/service/router.ts new file mode 100644 index 0000000000..e1243cedaf --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/router.ts @@ -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 { + 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; +} diff --git a/plugins/entity-feedback-backend/src/service/standaloneServer.ts b/plugins/entity-feedback-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..9395c72141 --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/standaloneServer.ts @@ -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 { + 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(); diff --git a/plugins/entity-feedback-backend/src/setupTests.ts b/plugins/entity-feedback-backend/src/setupTests.ts new file mode 100644 index 0000000000..aa70772592 --- /dev/null +++ b/plugins/entity-feedback-backend/src/setupTests.ts @@ -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 {}; diff --git a/plugins/entity-feedback-common/.eslintrc.js b/plugins/entity-feedback-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback-common/README.md b/plugins/entity-feedback-common/README.md new file mode 100644 index 0000000000..430d14aeab --- /dev/null +++ b/plugins/entity-feedback-common/README.md @@ -0,0 +1,3 @@ +# Entity Feedback Common + +Common types for the entity-feedback plugin. diff --git a/plugins/entity-feedback-common/api-report.md b/plugins/entity-feedback-common/api-report.md new file mode 100644 index 0000000000..118e6c78f2 --- /dev/null +++ b/plugins/entity-feedback-common/api-report.md @@ -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; +} +``` diff --git a/plugins/entity-feedback-common/package.json b/plugins/entity-feedback-common/package.json new file mode 100644 index 0000000000..6316b72943 --- /dev/null +++ b/plugins/entity-feedback-common/package.json @@ -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" + ] +} diff --git a/plugins/entity-feedback-common/src/index.ts b/plugins/entity-feedback-common/src/index.ts new file mode 100644 index 0000000000..eea289a899 --- /dev/null +++ b/plugins/entity-feedback-common/src/index.ts @@ -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; + }; +} diff --git a/plugins/entity-feedback-common/src/setupTests.ts b/plugins/entity-feedback-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/entity-feedback-common/src/setupTests.ts @@ -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 {}; diff --git a/plugins/entity-feedback/.eslintrc.js b/plugins/entity-feedback/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback/README.md b/plugins/entity-feedback/README.md new file mode 100644 index 0000000000..fdfa4761d6 --- /dev/null +++ b/plugins/entity-feedback/README.md @@ -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 + +![Like dislike rating example](./docs/like-dislike-rating.png) + +#### Starred rating + +![Starred rating example](./docs/starred-rating.png) + +### Request additional feedback when poorly rated + +![Response dialog example](./docs/feedback-response-dialog.png) + +### View entity feedback responses + +![Feedback responses example](./docs/feedback-response-table.png) + +### View aggregated ratings on owned entities + +#### Total likes/dislikes + +![Like dislike table example](./docs/like-dislike-table.png) + +#### Star breakdowns + +![Starred rating table example](./docs/starred-rating-table.png) + +## 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 = ( + + ... ++ ++ ++ ++ ++ + ... + +); + +... + +// Add to each applicable kind/type of entity as desired +const serviceEntityPage = ( + + ... ++ ++ ++ + ... + +); + +... + +// Add ratings card component to user/group entities to view ratings of owned entities +const userPage = ( + + + + ... ++ ++ ++ + ... + + + + +); + +const groupPage = ( + + + + ... ++ ++ ++ + ... + + + + +); +``` + +Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx) diff --git a/plugins/entity-feedback/api-report.md b/plugins/entity-feedback/api-report.md new file mode 100644 index 0000000000..06b0166b0d --- /dev/null +++ b/plugins/entity-feedback/api-report.md @@ -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 +/// + +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; + // (undocumented) + getOwnedRatings(ownerRef: string): Promise; + // (undocumented) + getRatings(entityRef: string): Promise[]>; + // (undocumented) + getResponses( + entityRef: string, + ): Promise[]>; + // (undocumented) + recordRating(entityRef: string, rating: string): Promise; + // (undocumented) + recordResponse( + entityRef: string, + response: Omit, + ): Promise; +} + +// @public (undocumented) +export const entityFeedbackApiRef: ApiRef; + +// @public (undocumented) +export class EntityFeedbackClient implements EntityFeedbackApi { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + // (undocumented) + getAllRatings(): Promise; + // (undocumented) + getOwnedRatings(ownerRef: string): Promise; + // (undocumented) + getRatings(entityRef: string): Promise[]>; + // (undocumented) + getResponses( + entityRef: string, + ): Promise[]>; + // (undocumented) + recordRating(entityRef: string, rating: string): Promise; + // (undocumented) + recordResponse( + entityRef: string, + response: Omit, + ): Promise; +} + +// @public (undocumented) +export const entityFeedbackPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {}, + {} +>; + +// @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; +} +``` diff --git a/plugins/entity-feedback/docs/feedback-response-dialog.png b/plugins/entity-feedback/docs/feedback-response-dialog.png new file mode 100644 index 0000000000..74270c805a Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-dialog.png differ diff --git a/plugins/entity-feedback/docs/feedback-response-table.png b/plugins/entity-feedback/docs/feedback-response-table.png new file mode 100644 index 0000000000..3795ac809d Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-table.png differ diff --git a/plugins/entity-feedback/docs/like-dislike-rating.png b/plugins/entity-feedback/docs/like-dislike-rating.png new file mode 100644 index 0000000000..0267218243 Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-rating.png differ diff --git a/plugins/entity-feedback/docs/like-dislike-table.png b/plugins/entity-feedback/docs/like-dislike-table.png new file mode 100644 index 0000000000..df38883d25 Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-table.png differ diff --git a/plugins/entity-feedback/docs/starred-rating-table.png b/plugins/entity-feedback/docs/starred-rating-table.png new file mode 100644 index 0000000000..e678c5b68a Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating-table.png differ diff --git a/plugins/entity-feedback/docs/starred-rating.png b/plugins/entity-feedback/docs/starred-rating.png new file mode 100644 index 0000000000..6198f7ae66 Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating.png differ diff --git a/plugins/entity-feedback/package.json b/plugins/entity-feedback/package.json new file mode 100644 index 0000000000..f8560a9237 --- /dev/null +++ b/plugins/entity-feedback/package.json @@ -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" + ] +} diff --git a/plugins/entity-feedback/src/api/EntityFeedbackApi.ts b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts new file mode 100644 index 0000000000..d2f1a46f55 --- /dev/null +++ b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts @@ -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({ + id: 'plugin.entity-feedback.service', +}); + +/** + * @public + */ +export interface EntityFeedbackApi { + getAllRatings(): Promise; + + getOwnedRatings(ownerRef: string): Promise; + + recordRating(entityRef: string, rating: string): Promise; + + getRatings(entityRef: string): Promise[]>; + + recordResponse( + entityRef: string, + response: Omit, + ): Promise; + + getResponses( + entityRef: string, + ): Promise[]>; +} diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts new file mode 100644 index 0000000000..694fd66b06 --- /dev/null +++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts @@ -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); + }); +}); diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts new file mode 100644 index 0000000000..34856473c8 --- /dev/null +++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts @@ -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 { + 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 { + 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[]> { + 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, + ) { + 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[]> { + 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(); + } +} diff --git a/plugins/entity-feedback/src/api/index.ts b/plugins/entity-feedback/src/api/index.ts new file mode 100644 index 0000000000..32c8624425 --- /dev/null +++ b/plugins/entity-feedback/src/api/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx new file mode 100644 index 0000000000..b639b2e727 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx @@ -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 = { + getAllRatings: jest.fn().mockImplementation(async () => sampleRatings), + getOwnedRatings: jest.fn().mockImplementation(async () => sampleRatings), + }; + + const sampleRatingValues = ['Rating 1', 'Rating 2']; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + { + 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(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx new file mode 100644 index 0000000000..48b2018729 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx @@ -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 ( + + } + 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 ( + + ); + } + + return ( + + columns={columns} + data={ratingsRows ?? []} + isLoading={loading} + options={{ + emptyRowsWhenPaging: false, + loadingType: 'linear', + pageSize: 20, + pageSizeOptions: [20, 50, 100], + paging: true, + showEmptyDataSourceMessage: !loading, + }} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts new file mode 100644 index 0000000000..565063b3fa --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx new file mode 100644 index 0000000000..631738d6f9 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx @@ -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 = { + kind: 'component', + metadata: { name: 'test', namespace: 'default' }, + }; + const errorApi: Partial = { post: jest.fn() }; + const feedbackApi: Partial = { + recordResponse: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + 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', + }, + ); + }); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx new file mode 100644 index 0000000000..c3f935e432 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx @@ -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 ( + !saving && onClose()}> + {saving && } + {feedbackDialogTitle} + + + Choose all that applies + + {feedbackDialogResponses.map(response => ( + + setResponseSelections({ + ...responseSelections, + [e.target.name]: e.target.checked, + }) + } + /> + } + label={response.label} + /> + ))} + + + + setComments(e.target.value)} + variant="outlined" + value={comments} + /> + + + Can we reach out to you for more info? + + No + + setConsent(e.target.checked)} + /> + + Yes + + + + + + + + + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts new file mode 100644 index 0000000000..8ce88f4b87 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx new file mode 100644 index 0000000000..c5d3d51884 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx @@ -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 = { + getResponses: jest.fn().mockImplementation(async () => sampleResponses), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + 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(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx new file mode 100644 index 0000000000..ba954c7747 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx @@ -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; + +const useStyles = makeStyles(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 ? : '', + }, + { + title: 'Responses', + field: 'response', + width: '35%', + render: (response: ResponseRow) => ( + <> + {response.response?.split(',').map(res => ( + + ))} + + ), + }, + { title: 'Comments', field: 'comments', width: '40%' }, + ]; + + if (error) { + return ( + + ); + } + + return ( + + 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} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts new file mode 100644 index 0000000000..73f4632fbf --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx new file mode 100644 index 0000000000..1edf578d3e --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx @@ -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 && dialog is open}; + }, +})); + +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 = { post: jest.fn() }; + + const feedbackApi: Partial = { + getRatings: jest.fn().mockImplementation(async () => sampleRatings), + recordRating: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: [], + }), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + + + , + ); + + 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(); + }); +}); diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx new file mode 100644 index 0000000000..2d38e60ac2 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx @@ -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.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 ; + } + + return ( + <> + applyRating(FeedbackRatings.like)} + > + {rating === FeedbackRatings.like ? ( + + ) : ( + + )} + + applyRating(FeedbackRatings.dislike)} + > + {rating === FeedbackRatings.dislike ? ( + + ) : ( + + )} + + setOpenFeedbackDialog(false)} + feedbackDialogResponses={feedbackDialogResponses} + feedbackDialogTitle={feedbackDialogTitle} + /> + + ); +}; diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts new file mode 100644 index 0000000000..58038c32e1 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx new file mode 100644 index 0000000000..9f04d32ece --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx @@ -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 ( + + {ratingValues.map(v => ( + {v} + ))} + + ); + }, +})); + +describe('LikeDislikeRatingsTable', () => { + it('renders like-dislike ratings correctly', async () => { + const rendered = await renderInTestApp(); + expect(rendered.getByText(FeedbackRatings.like)).toBeInTheDocument(); + expect(rendered.queryByText(FeedbackRatings.neutral)).toBeNull(); + expect(rendered.getByText(FeedbackRatings.dislike)).toBeInTheDocument(); + }); +}); diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx new file mode 100644 index 0000000000..5466ba2b03 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx @@ -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 ( + r !== FeedbackRatings.neutral, + )} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts new file mode 100644 index 0000000000..d093b8bd2c --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx new file mode 100644 index 0000000000..82fc843494 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx @@ -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 && dialog is open}; + }, +})); + +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 = { post: jest.fn() }; + + const feedbackApi: Partial = { + getRatings: jest.fn().mockImplementation(async () => sampleRatings), + recordRating: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: [], + }), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + + + , + ); + + 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(); + }); +}); diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx new file mode 100644 index 0000000000..51f7da1099 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx @@ -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(); + 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 ; + } + + return ( + <> + {Object.values(FeedbackRatings) + .filter(o => typeof o === 'number') + .map(starRating => ( + applyRating(starRating as FeedbackRatings)} + > + {rating && rating >= starRating ? ( + + ) : ( + + )} + + ))} + setOpenFeedbackDialog(false)} + feedbackDialogResponses={feedbackDialogResponses} + feedbackDialogTitle={feedbackDialogTitle} + /> + + ); +}; diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts new file mode 100644 index 0000000000..659c914749 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx new file mode 100644 index 0000000000..be53d59d5f --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx @@ -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 ( + + {ratingValues.map(v => ( + {v} + ))} + + ); + }, +})); + +describe('StarredRatingsTable', () => { + it('renders starred ratings correctly', async () => { + const rendered = await renderInTestApp(); + 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(); + }); +}); diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx new file mode 100644 index 0000000000..d52fd64640 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx @@ -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 ( + typeof o === 'number') + .map(r => r.toString())} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts new file mode 100644 index 0000000000..9621712e0b --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/components/index.ts b/plugins/entity-feedback/src/components/index.ts new file mode 100644 index 0000000000..a88d525f09 --- /dev/null +++ b/plugins/entity-feedback/src/components/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/index.ts b/plugins/entity-feedback/src/index.ts new file mode 100644 index 0000000000..bbcf6bc626 --- /dev/null +++ b/plugins/entity-feedback/src/index.ts @@ -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'; diff --git a/plugins/entity-feedback/src/plugin.test.ts b/plugins/entity-feedback/src/plugin.test.ts new file mode 100644 index 0000000000..a56d37f9ab --- /dev/null +++ b/plugins/entity-feedback/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/entity-feedback/src/plugin.tsx b/plugins/entity-feedback/src/plugin.tsx new file mode 100644 index 0000000000..9ebdc5d7af --- /dev/null +++ b/plugins/entity-feedback/src/plugin.tsx @@ -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 ( + + ); + }; + }, + ), + }), +); + +/** + * @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 ( + + ); + }; + }, + ), + }, + }), +); + +/** + * @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 ( + + ); + }; + }, + ), + }, + }), +); + +/** + * @public + */ +export const StarredRatingsTable = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'StarredRatingsTable', + component: { + lazy: () => + import('./components/StarredRatingsTable').then( + m => m.StarredRatingsTable, + ), + }, + }), +); diff --git a/plugins/entity-feedback/src/routes.ts b/plugins/entity-feedback/src/routes.ts new file mode 100644 index 0000000000..cde7241931 --- /dev/null +++ b/plugins/entity-feedback/src/routes.ts @@ -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', +}); diff --git a/plugins/entity-feedback/src/setupTests.ts b/plugins/entity-feedback/src/setupTests.ts new file mode 100644 index 0000000000..bf4855cb6e --- /dev/null +++ b/plugins/entity-feedback/src/setupTests.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts index f15747fd6b..a9ef8c3daa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts @@ -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); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index dae5e16374..75c8f25d8b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; -import { assertError, InputError } from '@backstage/errors'; +import { assertError, InputError, NotFoundError } from '@backstage/errors'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, @@ -135,6 +135,10 @@ export async function createGithubRepoWithCollaboratorsAndTopics( username: owner, }); + if (access?.startsWith(`${owner}/`)) { + await validateAccessTeam(client, access); + } + const repoCreationPromise = user.data.type === 'Organization' ? client.rest.repos.createInOrg({ @@ -351,3 +355,22 @@ function extractCollaboratorName( if ('user' in collaborator) return collaborator.user; return collaborator.team; } + +async function validateAccessTeam(client: Octokit, access: string) { + const [org, team_slug] = access.split('/'); + try { + // Below rule disabled because of a 'getByName' check for a different library + // incorrectly triggers here. + // eslint-disable-next-line testing-library/no-await-sync-query + await client.rest.teams.getByName({ + org, + team_slug, + }); + } catch (e) { + if (e.response.data.message === 'Not Found') { + const message = `Received 'Not Found' from the API; one of org: + ${org} or team: ${team_slug} was not found within GitHub.`; + throw new NotFoundError(message); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 8ead489ea4..94b51f3e94 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -45,6 +45,7 @@ const mockOctokit = { replaceAllTopics: jest.fn(), }, teams: { + getByName: jest.fn(), addOrUpdateRepoPermissionsInOrg: jest.fn(), }, }, @@ -96,11 +97,41 @@ describe('publish:github', () => { }); }); + it('should fail to create if the team is not found in the org', async () => { + mockOctokit.rest.users.getByUsername.mockResolvedValue({ + data: { type: 'Organization' }, + }); + + mockOctokit.rest.teams.getByName.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + }, + }, + }); + + await expect(action.handler(mockContext)).rejects.toThrow( + "Received 'Not Found' from the API;", + ); + + expect(mockOctokit.rest.repos.createInOrg).not.toHaveBeenCalled(); + }); + it('should call the githubApis with the correct values for createInOrg', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'Organization' }, }); + mockOctokit.rest.teams.getByName.mockResolvedValue({ + data: { + name: 'blam', + id: 42, + }, + }); + mockOctokit.rest.repos.createInOrg.mockResolvedValue({ data: {} }); await action.handler(mockContext); @@ -518,6 +549,30 @@ describe('publish:github', () => { }); }); + it('should provide an adequate failure message when adding access', async () => { + mockOctokit.rest.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockOctokit.rest.teams.getByName.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + }, + }, + }); + await expect(action.handler(mockContext)).rejects.toThrow( + "Received 'Not Found' from the API;", + ); + + expect( + mockOctokit.rest.repos.createForAuthenticatedUser, + ).not.toHaveBeenCalled(); + }); + it('should add outside collaborators when provided', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'User' }, diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index f8e1fb190c..772ec80982 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -24,6 +24,7 @@ import { oktaAuthApiRef, microsoftAuthApiRef, bitbucketAuthApiRef, + bitbucketServerAuthApiRef, atlassianAuthApiRef, oneloginAuthApiRef, } from '@backstage/core-plugin-api'; @@ -99,6 +100,14 @@ export const DefaultProviderSettings = (props: { icon={Star} /> )} + {configuredProviders.includes('bitbucketServer') && ( + + )} ); }; diff --git a/yarn.lock b/yarn.lock index 345c28a8b5..ee3c6f94c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5844,6 +5844,72 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-entity-feedback-backend@workspace:^, @backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-entity-feedback-common": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.12 + express: ^4.18.1 + express-promise-router: ^4.1.0 + knex: ^2.0.0 + msw: ^0.49.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-entity-feedback-common@workspace:^, @backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common" + dependencies: + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + +"@backstage/plugin-entity-feedback@workspace:^, @backstage/plugin-entity-feedback@workspace:plugins/entity-feedback": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback@workspace:plugins/entity-feedback" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-entity-feedback-common": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.57 + "@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 + 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 + languageName: unknown + linkType: soft + "@backstage/plugin-entity-validation@workspace:plugins/entity-validation": version: 0.0.0-use.local resolution: "@backstage/plugin-entity-validation@workspace:plugins/entity-validation" @@ -11244,7 +11310,7 @@ __metadata: languageName: node linkType: hard -"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4": +"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13": version: 4.12.4 resolution: "@material-ui/core@npm:4.12.4" dependencies: @@ -13628,7 +13694,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-hooks@npm:^8.0.0": +"@testing-library/react-hooks@npm:^8.0.0, @testing-library/react-hooks@npm:^8.0.1": version: 8.0.1 resolution: "@testing-library/react-hooks@npm:8.0.1" dependencies: @@ -22216,6 +22282,7 @@ __metadata: "@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:^" @@ -22318,6 +22385,7 @@ __metadata: "@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:^" @@ -38040,9 +38108,9 @@ __metadata: linkType: hard "web-streams-polyfill@npm:^3.2.0": - version: 3.2.0 - resolution: "web-streams-polyfill@npm:3.2.0" - checksum: e23ad0649392fa0159dbfc6bb27474c308c3f332d9078cfef3c06c154165bef18732c5814126147c6c712f604216ddc950c171c854e3821f020e0d2d721a5958 + version: 3.2.1 + resolution: "web-streams-polyfill@npm:3.2.1" + checksum: b119c78574b6d65935e35098c2afdcd752b84268e18746606af149e3c424e15621b6f1ff0b42b2676dc012fc4f0d313f964b41a4b5031e525faa03997457da02 languageName: node linkType: hard