Merge pull request #15881 from kuangp/feat/feedback-plugin

Entity Feedback Plugin
This commit is contained in:
Patrik Oldsberg
2023-02-13 21:32:17 +01:00
committed by GitHub
70 changed files with 4391 additions and 3 deletions
+7
View File
@@ -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!
+2
View File
@@ -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
@@ -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'
Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

+1
View File
@@ -30,6 +30,7 @@
"@backstage/plugin-code-coverage": "workspace:^",
"@backstage/plugin-cost-insights": "workspace:^",
"@backstage/plugin-dynatrace": "workspace:^",
"@backstage/plugin-entity-feedback": "workspace:^",
"@backstage/plugin-explore": "workspace:^",
"@backstage/plugin-gcalendar": "workspace:^",
"@backstage/plugin-gcp-projects": "workspace:^",
@@ -25,7 +25,7 @@ import {
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core-components';
import { EmptyState, InfoCard } from '@backstage/core-components';
import {
EntityApiDefinitionCard,
EntityConsumedApisCard,
@@ -79,6 +79,11 @@ import {
DynatraceTab,
isDynatraceAvailable,
} from '@backstage/plugin-dynatrace';
import {
EntityFeedbackResponseContent,
EntityLikeDislikeRatingsCard,
LikeDislikeButtons,
} from '@backstage/plugin-entity-feedback';
import {
EntityGithubActionsContent,
EntityRecentGithubActionsRunsCard,
@@ -376,6 +381,12 @@ const overviewContent = (
</EntitySwitch.Case>
</EntitySwitch>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
</InfoCard>
</Grid>
{cicdCard}
<EntitySwitch>
@@ -513,6 +524,10 @@ const serviceEntityPage = (
>
<DynatraceTab />
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -592,6 +607,10 @@ const websiteEntityPage = (
<EntityLayout.Route path="/todos" title="TODOs">
<EntityTodoContent />
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -608,6 +627,10 @@ const defaultEntityPage = (
<EntityLayout.Route path="/todos" title="TODOs">
<EntityTodoContent />
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -644,6 +667,11 @@ const apiPage = (
<Grid item xs={12} md={6}>
<EntityConsumingComponentsCard />
</Grid>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
</InfoCard>
</Grid>
</Grid>
</Grid>
</Grid>
@@ -656,6 +684,10 @@ const apiPage = (
</Grid>
</Grid>
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -673,6 +705,9 @@ const userPage = (
entityFilterKind={customEntityFilterKind}
/>
</Grid>
<Grid item xs={12}>
<EntityLikeDislikeRatingsCard />
</Grid>
</Grid>
</EntityLayout.Route>
</EntityLayoutWrapper>
@@ -695,6 +730,9 @@ const groupPage = (
<Grid item xs={12}>
<EntityMembersListCard />
</Grid>
<Grid item xs={12}>
<EntityLikeDislikeRatingsCard />
</Grid>
</Grid>
</EntityLayout.Route>
</EntityLayoutWrapper>
@@ -720,6 +758,11 @@ const systemPage = (
<Grid item md={6}>
<EntityHasResourcesCard variant="gridItem" />
</Grid>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
</InfoCard>
</Grid>
</Grid>
</EntityLayout.Route>
<EntityLayout.Route path="/score" title="Score">
@@ -748,6 +791,9 @@ const systemPage = (
unidirectional={false}
/>
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -765,8 +811,16 @@ const domainPage = (
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
</Grid>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
</InfoCard>
</Grid>
</Grid>
</EntityLayout.Route>
<EntityLayout.Route path="/feedback" title="Feedback">
<EntityFeedbackResponseContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
+1
View File
@@ -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:^",
+5
View File
@@ -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());
@@ -0,0 +1,28 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouter } from '@backstage/plugin-entity-feedback-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default function createPlugin(env: PluginEnvironment): Promise<Router> {
return createRouter({
database: env.database,
discovery: env.discovery,
identity: env.identity,
logger: env.logger,
});
}
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+50
View File
@@ -0,0 +1,50 @@
# Entity Feedback Backend
Welcome to the entity-feedback backend plugin!
## Installation
### Install the package
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-entity-feedback-backend
```
### Adding the plugin to your `packages/backend`
You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/entityFeedback.ts`
```tsx
import { createRouter } from '@backstage/plugin-entity-feedback-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default function createPlugin(env: PluginEnvironment): Promise<Router> {
return createRouter({
database: env.database,
discovery: env.discovery,
identity: env.identity,
logger: env.logger,
});
}
```
With the `entityFeedback.ts` router setup in place, add the router to `packages/backend/src/index.ts`:
```diff
+import entityFeedback from './plugins/entityFeedback';
async function main() {
...
const createEnv = makeCreateEnv(config);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
+ const entityFeedbackEnv = useHotMemoize(module, () => createEnv('entityFeedback'));
const apiRouter = Router();
+ apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv));
...
apiRouter.use(notFoundHandler());
```
@@ -0,0 +1,26 @@
## API Report File for "@backstage/plugin-entity-feedback-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import express from 'express';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
identity: IdentityApi;
// (undocumented)
logger: Logger;
}
```
@@ -0,0 +1,69 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param { import("knex").Knex } knex
*/
exports.up = async function up(knex) {
await knex.schema.createTable('ratings', table => {
table.comment('Ratings table');
table
.string('entity_ref')
.notNullable()
.comment('The ref of the entity being rated');
table
.string('user_ref')
.notNullable()
.comment('The user applying the rating');
table.string('rating').notNullable().comment('The rating value');
table
.timestamp('timestamp')
.defaultTo(knex.fn.now())
.notNullable()
.comment('When the rating was recorded');
});
await knex.schema.createTable('responses', table => {
table.comment('Responses table');
table
.string('entity_ref')
.notNullable()
.comment('The ref of the applicable entity');
table.string('user_ref').notNullable().comment('The user responding');
table.text('response').comment('The serialized response');
table.text('comments').comment('Additional user comments');
table
.boolean('consent')
.defaultTo(true)
.notNullable()
.comment('Whether user (if recorded) consents to being contacted');
table
.timestamp('timestamp')
.defaultTo(knex.fn.now())
.notNullable()
.comment('When the response was recorded');
});
};
/**
* @param { import("knex").Knex } knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('ratings');
await knex.schema.dropTable('responses');
};
@@ -0,0 +1,50 @@
{
"name": "@backstage/plugin-entity-feedback-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-entity-feedback-common": "workspace:^",
"@types/express": "*",
"express": "^4.18.1",
"express-promise-router": "^4.1.0",
"knex": "^2.0.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.12",
"msw": "^0.49.0",
"supertest": "^6.2.4"
},
"files": [
"dist",
"migrations/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,22 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Entity Feedback backend plugin
*
* @packageDocumentation
*/
export * from './service/router';
@@ -0,0 +1,33 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,310 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Knex } from 'knex';
import { DatabaseHandler } from './DatabaseHandler';
describe('DatabaseHandler', () => {
const databases = TestDatabases.create();
async function createDatabaseHandler(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
return {
knex,
dbHandler: await DatabaseHandler.create({ database: knex }),
};
}
describe.each(databases.eachSupportedId())(
'%p',
databaseId => {
let knex: Knex;
let dbHandler: DatabaseHandler;
beforeEach(async () => {
({ knex, dbHandler } = await createDatabaseHandler(databaseId));
}, 30000);
afterEach(async () => {
// Clean up after each test
await knex('ratings').del();
await knex('responses').del();
}, 30000);
it('getAllRatedEntities', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
},
{
entity_ref: 'component:default/library',
user_ref: 'user:default/bar',
rating: 'LIKE',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
},
]);
const entities = await dbHandler.getAllRatedEntities();
expect(entities.length).toEqual(3);
expect(entities).toEqual(
expect.arrayContaining([
'component:default/service',
'component:default/library',
'component:default/website',
]),
);
});
it('getRatingsAggregates', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/test',
rating: 'DISLIKE',
timestamp: '2004-11-20 08:23:54',
},
]);
let ratings = await dbHandler.getRatingsAggregates([
'component:default/service',
'component:default/website',
]);
expect(ratings.length).toEqual(3);
expect(ratings).toEqual(
expect.arrayContaining([
{
entityRef: 'component:default/service',
rating: 'LIKE',
count: 2,
},
{
entityRef: 'component:default/website',
rating: 'DISLIKE',
count: 1,
},
{
entityRef: 'component:default/website',
rating: 'LIKE',
count: 1,
},
]),
);
ratings = await dbHandler.getRatingsAggregates([
'component:default/website',
]);
expect(ratings.length).toEqual(2);
expect(ratings).toEqual(
expect.arrayContaining([
{
entityRef: 'component:default/website',
rating: 'DISLIKE',
count: 1,
},
{
entityRef: 'component:default/website',
rating: 'LIKE',
count: 1,
},
]),
);
});
it('recordRating', async () => {
const newRating = {
userRef: 'user:default/me',
entityRef: 'component:default/service',
rating: 'LIKE',
};
await dbHandler.recordRating(newRating);
const ratings = await knex('ratings').select();
expect(ratings.length).toEqual(1);
expect(ratings[0]).toEqual(
expect.objectContaining({
user_ref: newRating.userRef,
entity_ref: newRating.entityRef,
rating: newRating.rating,
timestamp: expect.anything(),
}),
);
});
it('getRatings', async () => {
await knex('ratings').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'DISLIKE',
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
rating: 'LIKE',
timestamp: '2004-11-20 08:23:54',
},
]);
const ratings = await dbHandler.getRatings('component:default/service');
expect(ratings.length).toEqual(2);
expect(ratings).toEqual(
expect.arrayContaining([
{ userRef: 'user:default/foo', rating: 'LIKE' },
{ userRef: 'user:default/bar', rating: 'LIKE' },
]),
);
});
it('recordResponse', async () => {
const newResponse = {
userRef: 'user:default/me',
entityRef: 'component:default/service',
response: 'blah',
comments: 'here is some feedback',
consent: false,
};
await dbHandler.recordResponse(newResponse);
const responses = await knex('responses').select();
expect(responses.length).toEqual(1);
expect({
...responses[0],
consent: Boolean(responses[0].consent),
}).toEqual(
expect.objectContaining({
user_ref: newResponse.userRef,
entity_ref: newResponse.entityRef,
response: newResponse.response,
comments: newResponse.comments,
consent: newResponse.consent,
timestamp: expect.anything(),
}),
);
});
it('getResponses', async () => {
await knex('responses').insert([
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
response: 'blah',
comments: 'here is some feedback',
consent: true,
timestamp: '2004-10-19 10:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
timestamp: '2004-11-19 08:23:54',
},
{
entity_ref: 'component:default/service',
user_ref: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
timestamp: '2004-11-20 08:23:54',
},
{
entity_ref: 'component:default/website',
user_ref: 'user:default/foo',
response: 'cool',
comments: 'no comment',
consent: true,
timestamp: '2004-11-20 08:23:54',
},
]);
const responses = await dbHandler.getResponses(
'component:default/service',
);
expect(responses.length).toEqual(2);
expect(responses).toEqual(
expect.arrayContaining([
{
userRef: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
},
{
userRef: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
},
]),
);
});
},
60000,
);
});
@@ -0,0 +1,164 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolvePackagePath } from '@backstage/backend-common';
import {
FeedbackResponse,
Rating,
} from '@backstage/plugin-entity-feedback-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-entity-feedback-backend',
'migrations',
);
type RatingsAggregatesResult = {
entityRef: string;
rating: string;
count: number;
}[];
type Options = {
database: Knex;
};
/**
* @public
*/
export class DatabaseHandler {
static async create(options: Options): Promise<DatabaseHandler> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseHandler(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async getAllRatedEntities(): Promise<string[]> {
return (await this.database('ratings').distinct('entity_ref')).map(
({ entity_ref }) => entity_ref,
);
}
async getRatingsAggregates(
entityRefs: string[],
): Promise<RatingsAggregatesResult> {
return (
await this.database('ratings')
.whereIn('ratings.entity_ref', entityRefs)
.innerJoin(
this.database('ratings')
.as('latest_ratings')
.select('entity_ref', 'user_ref')
.max('timestamp', { as: 'timestamp' })
.whereIn('entity_ref', entityRefs)
.groupBy('entity_ref', 'user_ref'),
function joinClause() {
this.on('ratings.entity_ref', '=', 'latest_ratings.entity_ref')
.andOn('ratings.user_ref', '=', 'latest_ratings.user_ref')
.andOn('ratings.timestamp', '=', 'latest_ratings.timestamp');
},
)
.select('ratings.entity_ref', 'rating')
.count('ratings.user_ref as count')
.groupBy('ratings.entity_ref', 'rating')
).map(({ entity_ref, rating, count }) => ({
entityRef: entity_ref as string,
rating: rating as string,
count: Number(count),
}));
}
async recordRating(rating: Rating) {
await this.database('ratings').insert({
entity_ref: rating.entityRef,
rating: rating.rating,
user_ref: rating.userRef,
});
}
async getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]> {
return (
await this.database('ratings')
.where('entity_ref', entityRef)
.innerJoin(
this.database('ratings')
.as('latest_ratings')
.select('user_ref')
.max('timestamp', { as: 'timestamp' })
.where('entity_ref', entityRef)
.groupBy('user_ref'),
function joinClause() {
this.on('ratings.user_ref', '=', 'latest_ratings.user_ref').andOn(
'ratings.timestamp',
'=',
'latest_ratings.timestamp',
);
},
)
.select('ratings.user_ref', 'rating')
).map(rating => ({ userRef: rating.user_ref, rating: rating.rating }));
}
async recordResponse(response: FeedbackResponse) {
await this.database('responses').insert({
entity_ref: response.entityRef,
response: response.response,
comments: response.comments,
consent: response.consent,
user_ref: response.userRef,
});
}
async getResponses(
entityRef: string,
): Promise<Omit<FeedbackResponse, 'entityRef'>[]> {
return (
await this.database('responses')
.where('entity_ref', entityRef)
.innerJoin(
this.database('responses')
.as('latest_responses')
.select('user_ref')
.max('timestamp', { as: 'timestamp' })
.where('entity_ref', entityRef)
.groupBy('user_ref'),
function joinClause() {
this.on(
'responses.user_ref',
'=',
'latest_responses.user_ref',
).andOn('responses.timestamp', '=', 'latest_responses.timestamp');
},
)
.select('responses.user_ref', 'response', 'comments', 'consent')
).map(response => ({
userRef: response.user_ref,
response: response.response,
comments: response.comments,
consent: Boolean(response.consent),
}));
}
}
@@ -0,0 +1,290 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
DatabaseManager,
getVoidLogger,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { IdentityApi } from '@backstage/plugin-auth-node';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
const sampleOwnedEntities = [
{
kind: 'component',
metadata: {
namespace: 'default',
name: 'foo',
title: 'Foo Component',
},
},
{
kind: 'component',
metadata: {
namespace: 'default',
name: 'bar',
title: 'Bar Component',
},
},
];
const sampleEntities = [
...sampleOwnedEntities,
{
kind: 'user',
metadata: {
namespace: 'default',
name: 'foo',
},
},
null,
{
kind: 'user',
metadata: {
namespace: 'default',
name: 'bar',
},
},
];
const mockGetEntties = jest
.fn()
.mockImplementation(async () => ({ items: sampleOwnedEntities }));
const mockGetEnttiesByRefs = jest
.fn()
.mockImplementation(async () => ({ items: sampleEntities }));
jest.mock('@backstage/catalog-client', () => ({
CatalogClient: jest.fn().mockImplementation(() => ({
getEntities: mockGetEntties,
getEntitiesByRefs: mockGetEnttiesByRefs,
})),
}));
jest.mock('@backstage/plugin-auth-node', () => ({
getBearerTokenFromAuthorizationHeader: () => 'token',
}));
const mockRatings = [
{ userRef: 'user:default/foo', rating: 'LIKE' },
{ userRef: 'user:default/bar', rating: 'LIKE' },
{ userRef: 'user:default/test', rating: 'DISLIKE' },
];
const mockResponses = [
{
userRef: 'user:default/foo',
response: 'asdf',
comments: 'here is new feedback',
consent: false,
},
{
userRef: 'user:default/bar',
response: 'noop',
comments: 'here is different feedback',
consent: true,
},
{
userRef: 'user:default/test',
response: 'err',
comments: 'no comment',
consent: false,
},
];
const mockDbHandler = {
getAllRatedEntities: jest
.fn()
.mockImplementation(async () => [
'component:default/foo',
'component:default/bar',
'component:default/test',
]),
getRatingsAggregates: jest.fn().mockImplementation(async () => [
{ entityRef: 'component:default/foo', rating: 'LIKE', count: 3 },
{ entityRef: 'component:default/foo', rating: 'DISLIKE', count: 1 },
{ entityRef: 'component:default/bar', rating: 'LIKE', count: 5 },
]),
recordRating: jest.fn().mockImplementation(async () => {}),
getRatings: jest.fn().mockImplementation(async () => mockRatings),
recordResponse: jest.fn().mockImplementation(async () => {}),
getResponses: jest.fn().mockImplementation(async () => mockResponses),
};
jest.mock('./DatabaseHandler', () => ({
DatabaseHandler: { create: async () => mockDbHandler },
}));
describe('createRouter', () => {
let app: express.Express;
const createDatabase = () =>
DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
}),
).forPlugin('entity-feedback');
const mockIdentityClient = {
getIdentity: jest.fn().mockImplementation(async () => ({
identity: { userEntityRef: 'user:default/me' },
})),
} as unknown as IdentityApi;
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
beforeEach(async () => {
const router = await createRouter({
database: createDatabase(),
discovery,
identity: mockIdentityClient,
logger: getVoidLogger(),
});
app = express().use(router);
jest.clearAllMocks();
});
describe('GET /ratings', () => {
it('should get ratings for all entities correctly', async () => {
const response = await request(app).get('/ratings').send();
expect(mockDbHandler.getAllRatedEntities).toHaveBeenCalled();
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
sampleEntities
.filter(Boolean)
.map((ent: any) => stringifyEntityRef(ent)),
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
entityRef: 'component:default/foo',
entityTitle: 'Foo Component',
ratings: { LIKE: 3, DISLIKE: 1 },
},
{
entityRef: 'component:default/bar',
entityTitle: 'Bar Component',
ratings: { LIKE: 5 },
},
]);
});
it('should get ratings for all owned entities correctly', async () => {
const response = await request(app)
.get('/ratings?ownerRef=group:default/test-team')
.send();
expect(mockGetEntties).toHaveBeenCalledWith(
expect.objectContaining({
filter: { 'relations.ownedBy': 'group:default/test-team' },
}),
{ token: 'token' },
);
expect(mockDbHandler.getAllRatedEntities).not.toHaveBeenCalled();
expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
sampleOwnedEntities.map((ent: any) => stringifyEntityRef(ent)),
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
entityRef: 'component:default/foo',
entityTitle: 'Foo Component',
ratings: { LIKE: 3, DISLIKE: 1 },
},
{
entityRef: 'component:default/bar',
entityTitle: 'Bar Component',
ratings: { LIKE: 5 },
},
]);
});
});
describe('POST /ratings/:entityRef', () => {
it('should record a rating correctly', async () => {
const body = { rating: 'LIKE' };
const response = await request(app)
.post('/ratings/component%3Adefault%2Fservice')
.send(body);
expect(mockDbHandler.recordRating).toHaveBeenCalledWith({
entityRef: 'component:default/service',
userRef: 'user:default/me',
...body,
});
expect(response.status).toEqual(201);
});
});
describe('GET /ratings/:entityRef', () => {
it('should get ratings for an entity correctly', async () => {
const response = await request(app)
.get('/ratings/component%3Adefault%2Fservice')
.send();
expect(mockDbHandler.getRatings).toHaveBeenCalledWith(
'component:default/service',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(
mockRatings.filter(r => r.userRef !== 'user:default/test'),
);
});
});
describe('POST /responses/:entityRef', () => {
it('should record a response correctly', async () => {
const body = { response: 'blah', comments: 'feedback', consent: true };
const response = await request(app)
.post('/responses/component%3Adefault%2Fservice')
.send(body);
expect(mockDbHandler.recordResponse).toHaveBeenCalledWith({
entityRef: 'component:default/service',
userRef: 'user:default/me',
...body,
});
expect(response.status).toEqual(201);
});
});
describe('GET /responses/:entityRef', () => {
it('should get responses for an entity correctly', async () => {
const response = await request(app)
.get('/responses/component%3Adefault%2Fservice')
.send();
expect(mockDbHandler.getResponses).toHaveBeenCalledWith(
'component:default/service',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(
mockResponses.filter(r => r.userRef !== 'user:default/test'),
);
});
});
});
@@ -0,0 +1,215 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
errorHandler,
PluginDatabaseManager,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
getBearerTokenFromAuthorizationHeader,
IdentityApi,
} from '@backstage/plugin-auth-node';
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { DatabaseHandler } from './DatabaseHandler';
/**
* @public
*/
export interface RouterOptions {
database: PluginDatabaseManager;
discovery: PluginEndpointDiscovery;
identity: IdentityApi;
logger: Logger;
}
/**
* @public
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { database, discovery, identity, logger } = options;
logger.info('Initializing Entity Feedback backend');
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const db = await database.getClient();
const dbHandler = await DatabaseHandler.create({ database: db });
const router = Router();
router.use(express.json());
router.get('/ratings', async (req, res) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
const requestedEntities: { [ref: string]: Entity } = {};
if (req.query.ownerRef) {
// Get ratings from all owned entities (also ensures only accessible entities are requested)
(
await catalogClient.getEntities(
{
filter: { 'relations.ownedBy': req.query.ownerRef as string },
fields: [
'kind',
'metadata.name',
'metadata.namespace',
'metadata.title',
],
},
{ token },
)
).items.forEach(ent => {
requestedEntities[stringifyEntityRef(ent)] = ent;
});
} else {
const allRatedEntities = await dbHandler.getAllRatedEntities();
// Filter entities to only expose entity refs accessible by current user
(
await catalogClient.getEntitiesByRefs(
{
entityRefs: allRatedEntities,
fields: [
'kind',
'metadata.namespace',
'metadata.name',
'metadata.title',
],
},
{ token },
)
).items
.filter(Boolean)
.forEach(ent => {
requestedEntities[stringifyEntityRef(ent!)] = ent!;
});
}
const entityRatings = await dbHandler.getRatingsAggregates(
Object.keys(requestedEntities),
);
// Merge rating aggregates into a condensed per entity structure
const entityRatingsMap = entityRatings.reduce<{
[ref: string]: EntityRatingsData;
}>((ratingsMap, { entityRef, rating, count }) => {
ratingsMap[entityRef] = ratingsMap[entityRef] ?? {
entityRef,
entityTitle: requestedEntities[entityRef].metadata.title,
ratings: {},
};
ratingsMap[entityRef].ratings[rating] = count;
return ratingsMap;
}, {});
res.json(Object.values(entityRatingsMap));
});
router.post('/ratings/:entityRef', async (req, res) => {
const user = await identity.getIdentity({ request: req });
const rating = req.body.rating;
if (!user || !rating) {
res.status(400).end();
return;
}
await dbHandler.recordRating({
entityRef: req.params.entityRef,
rating,
userRef: user.identity.userEntityRef,
});
res.status(201).end();
});
router.get('/ratings/:entityRef', async (req, res) => {
const ratings = await dbHandler.getRatings(req.params.entityRef);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
// Filter ratings via user refs to only expose entity refs accessible by current user
const accessibleEntityRefs = (
await catalogClient.getEntitiesByRefs(
{
entityRefs: ratings.map(r => r.userRef),
fields: ['kind', 'metadata.namespace', 'metadata.name'],
},
{ token },
)
).items
.filter(Boolean)
.map(ent => stringifyEntityRef(ent!));
res.json(ratings.filter(r => accessibleEntityRefs.includes(r.userRef)));
});
router.post('/responses/:entityRef', async (req, res) => {
const user = await identity.getIdentity({ request: req });
const { response, comments, consent } = req.body;
if (!user) {
res.status(400).end();
return;
}
await dbHandler.recordResponse({
entityRef: req.params.entityRef,
response,
comments,
consent,
userRef: user.identity.userEntityRef,
});
res.status(201).end();
});
router.get('/responses/:entityRef', async (req, res) => {
const responses = await dbHandler.getResponses(req.params.entityRef);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
// Filter responses via user refs to only expose entity refs accessible by current user
const accessibleEntityRefs = (
await catalogClient.getEntitiesByRefs(
{
entityRefs: responses.map(r => r.userRef),
fields: ['kind', 'metadata.namespace', 'metadata.name'],
},
{ token },
)
).items
.filter(Boolean)
.map(ent => stringifyEntityRef(ent!));
res.json(responses.filter(r => accessibleEntityRefs.includes(r.userRef)));
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,80 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceBuilder,
DatabaseManager,
loadBackendConfig,
SingleHostDiscovery,
useHotMemoize,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'entity-feedback-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = SingleHostDiscovery.fromConfig(config);
const database = useHotMemoize(module, () => {
const manager = DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: { client: 'better-sqlite3', connection: ':memory:' },
},
}),
);
return manager.forPlugin('entity-feedback');
});
const identity = DefaultIdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
});
logger.debug('Starting application server...');
const router = await createRouter({
database,
discovery,
identity,
logger,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/entity-feedback', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+3
View File
@@ -0,0 +1,3 @@
# Entity Feedback Common
Common types for the entity-feedback plugin.
@@ -0,0 +1,41 @@
## API Report File for "@backstage/plugin-entity-feedback-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public (undocumented)
export interface EntityRatingsData {
// (undocumented)
entityRef: string;
// (undocumented)
entityTitle?: string;
// (undocumented)
ratings: {
[ratingValue: string]: number;
};
}
// @public (undocumented)
export interface FeedbackResponse {
// (undocumented)
comments?: string;
// (undocumented)
consent?: boolean;
// (undocumented)
entityRef: string;
// (undocumented)
response?: string;
// (undocumented)
userRef: string;
}
// @public (undocumented)
export interface Rating {
// (undocumented)
entityRef: string;
// (undocumented)
rating: string;
// (undocumented)
userRef: string;
}
```
@@ -0,0 +1,31 @@
{
"name": "@backstage/plugin-entity-feedback-common",
"description": "Common functionalities for the entity-feedback plugin",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
},
"files": [
"dist"
]
}
@@ -0,0 +1,52 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Common functionalities for the entity-feedback plugin.
*
* @packageDocumentation
*/
/**
* @public
*/
export interface Rating {
entityRef: string;
rating: string;
userRef: string;
}
/**
* @public
*/
export interface FeedbackResponse {
entityRef: string;
response?: string;
comments?: string;
consent?: boolean;
userRef: string;
}
/**
* @public
*/
export interface EntityRatingsData {
entityRef: string;
entityTitle?: string;
ratings: {
[ratingValue: string]: number;
};
}
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+136
View File
@@ -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 = (
<Grid container spacing={3} alignItems="stretch">
...
+ <Grid item md={2}>
+ <InfoCard title="Rate this entity">
+ <LikeDislikeButtons />
+ </InfoCard>
+ </Grid>
...
</Grid>
);
...
// Add to each applicable kind/type of entity as desired
const serviceEntityPage = (
<EntityLayoutWrapper>
...
+ <EntityLayout.Route path="/feedback" title="Feedback">
+ <EntityFeedbackResponseContent />
+ </EntityLayout.Route>
...
</EntityLayoutWrapper>
);
...
// Add ratings card component to user/group entities to view ratings of owned entities
const userPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
...
+ <Grid item xs={12}>
+ <EntityLikeDislikeRatingsCard />
+ </Grid>
...
</Grid>
</Grid>
</EntityLayout.Route>
</EntityLayoutWrapper>
);
const groupPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
...
+ <Grid item xs={12}>
+ <EntityLikeDislikeRatingsCard />
+ </Grid>
...
</Grid>
</Grid>
</EntityLayout.Route>
</EntityLayoutWrapper>
);
```
Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx)
+184
View File
@@ -0,0 +1,184 @@
## API Report File for "@backstage/plugin-entity-feedback"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common';
import { FetchApi } from '@backstage/core-plugin-api';
import { Rating } from '@backstage/plugin-entity-feedback-common';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
export interface EntityFeedbackApi {
// (undocumented)
getAllRatings(): Promise<EntityRatingsData[]>;
// (undocumented)
getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]>;
// (undocumented)
getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]>;
// (undocumented)
getResponses(
entityRef: string,
): Promise<Omit<FeedbackResponse, 'entityRef'>[]>;
// (undocumented)
recordRating(entityRef: string, rating: string): Promise<void>;
// (undocumented)
recordResponse(
entityRef: string,
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
): Promise<void>;
}
// @public (undocumented)
export const entityFeedbackApiRef: ApiRef<EntityFeedbackApi>;
// @public (undocumented)
export class EntityFeedbackClient implements EntityFeedbackApi {
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
// (undocumented)
getAllRatings(): Promise<EntityRatingsData[]>;
// (undocumented)
getOwnedRatings(ownerRef: string): Promise<EntityRatingsData[]>;
// (undocumented)
getRatings(entityRef: string): Promise<Omit<Rating, 'entityRef'>[]>;
// (undocumented)
getResponses(
entityRef: string,
): Promise<Omit<FeedbackResponse, 'entityRef'>[]>;
// (undocumented)
recordRating(entityRef: string, rating: string): Promise<void>;
// (undocumented)
recordResponse(
entityRef: string,
response: Omit<FeedbackResponse, 'entityRef' | 'userRef'>,
): Promise<void>;
}
// @public (undocumented)
export const entityFeedbackPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{},
{}
>;
// @public (undocumented)
export interface EntityFeedbackResponse {
// (undocumented)
id: string;
// (undocumented)
label: string;
}
// @public (undocumented)
export const EntityFeedbackResponseContent: () => JSX.Element;
// @public (undocumented)
export const EntityLikeDislikeRatingsCard: () => JSX.Element;
// @public (undocumented)
export const EntityStarredRatingsCard: () => JSX.Element;
// @public (undocumented)
export const FeedbackResponseDialog: (
props: FeedbackResponseDialogProps,
) => JSX.Element;
// @public (undocumented)
export interface FeedbackResponseDialogProps {
// (undocumented)
entity: Entity;
// (undocumented)
feedbackDialogResponses?: EntityFeedbackResponse[];
// (undocumented)
feedbackDialogTitle?: ReactNode;
// (undocumented)
onClose: () => void;
// (undocumented)
open: boolean;
}
// @public (undocumented)
export const FeedbackResponseTable: (
props: FeedbackResponseTableProps,
) => JSX.Element;
// @public (undocumented)
export interface FeedbackResponseTableProps {
// (undocumented)
entityRef: string;
// (undocumented)
title?: string;
}
// @public (undocumented)
export const LikeDislikeButtons: (
props: LikeDislikeButtonsProps,
) => JSX.Element;
// @public (undocumented)
export interface LikeDislikeButtonsProps {
// (undocumented)
feedbackDialogResponses?: EntityFeedbackResponse[];
// (undocumented)
feedbackDialogTitle?: ReactNode;
// (undocumented)
requestResponse?: boolean;
}
// @public (undocumented)
export const LikeDislikeRatingsTable: (
props: LikeDislikeRatingsTableProps,
) => JSX.Element;
// @public (undocumented)
export interface LikeDislikeRatingsTableProps {
// (undocumented)
allEntities?: boolean;
// (undocumented)
ownerRef?: string;
// (undocumented)
title?: string;
}
// @public (undocumented)
export const StarredRatingButtons: (
props: StarredRatingButtonsProps,
) => JSX.Element;
// @public (undocumented)
export interface StarredRatingButtonsProps {
// (undocumented)
feedbackDialogResponses?: EntityFeedbackResponse[];
// (undocumented)
feedbackDialogTitle?: ReactNode;
// (undocumented)
requestResponse?: boolean;
// (undocumented)
requestResponseThreshold?: number;
}
// @public (undocumented)
export const StarredRatingsTable: (
props: StarredRatingsTableProps,
) => JSX.Element;
// @public (undocumented)
export interface StarredRatingsTableProps {
// (undocumented)
allEntities?: boolean;
// (undocumented)
ownerRef?: string;
// (undocumented)
title?: string;
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

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