diff --git a/.changeset/gorgeous-llamas-mate.md b/.changeset/gorgeous-llamas-mate.md
new file mode 100644
index 0000000000..a2553875e9
--- /dev/null
+++ b/.changeset/gorgeous-llamas-mate.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-entity-feedback-backend': minor
+'@backstage/plugin-entity-feedback-common': minor
+'@backstage/plugin-entity-feedback': minor
+---
+
+Implement entity feedback plugin, check out the `README.md` for more details!
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 1c6171cb01..10f1ac188c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -37,6 +37,8 @@ yarn.lock @backstage/maintainers @back
/plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva
/plugins/cost-insights @backstage/maintainers @backstage/silver-lining
/plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining
+/plugins/entity-feedback @backstage/maintainers @kuangp
+/plugins/entity-feedback-* @backstage/maintainers @kuangp
/plugins/events-backend @backstage/maintainers @pjungermann
/plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann
/plugins/events-backend-module-azure @backstage/maintainers @pjungermann
diff --git a/microsite/data/plugins/entity-feedback.yaml b/microsite/data/plugins/entity-feedback.yaml
new file mode 100644
index 0000000000..c40d0450ca
--- /dev/null
+++ b/microsite/data/plugins/entity-feedback.yaml
@@ -0,0 +1,10 @@
+---
+title: Entity Feedback
+author: Phil Kuang
+authorUrl: https://github.com/kuangp
+category: Quality
+description: Give and view feedback on entities available in the Backstage catalog.
+documentation: https://github.com/backstage/backstage/tree/master/plugins/entity-feedback
+iconUrl: img/entity-feedback-logo.png
+npmPackageName: '@backstage/plugin-entity-feedback'
+addedDate: '2023-01-20'
diff --git a/microsite/static/img/entity-feedback-logo.png b/microsite/static/img/entity-feedback-logo.png
new file mode 100644
index 0000000000..e172b260a0
Binary files /dev/null and b/microsite/static/img/entity-feedback-logo.png differ
diff --git a/packages/app/package.json b/packages/app/package.json
index 80ee11ea15..afb1738207 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -30,6 +30,7 @@
"@backstage/plugin-code-coverage": "workspace:^",
"@backstage/plugin-cost-insights": "workspace:^",
"@backstage/plugin-dynatrace": "workspace:^",
+ "@backstage/plugin-entity-feedback": "workspace:^",
"@backstage/plugin-explore": "workspace:^",
"@backstage/plugin-gcalendar": "workspace:^",
"@backstage/plugin-gcp-projects": "workspace:^",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 3ab0e83238..36a8b7553f 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -25,7 +25,7 @@ import {
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
-import { EmptyState } from '@backstage/core-components';
+import { EmptyState, InfoCard } from '@backstage/core-components';
import {
EntityApiDefinitionCard,
EntityConsumedApisCard,
@@ -79,6 +79,11 @@ import {
DynatraceTab,
isDynatraceAvailable,
} from '@backstage/plugin-dynatrace';
+import {
+ EntityFeedbackResponseContent,
+ EntityLikeDislikeRatingsCard,
+ LikeDislikeButtons,
+} from '@backstage/plugin-entity-feedback';
import {
EntityGithubActionsContent,
EntityRecentGithubActionsRunsCard,
@@ -376,6 +381,12 @@ const overviewContent = (
+
+
+
+
+
+
{cicdCard}
@@ -513,6 +524,10 @@ const serviceEntityPage = (
>
+
+
+
+
);
@@ -592,6 +607,10 @@ const websiteEntityPage = (
+
+
+
+
);
@@ -608,6 +627,10 @@ const defaultEntityPage = (
+
+
+
+
);
@@ -644,6 +667,11 @@ const apiPage = (
+
+
+
+
+
@@ -656,6 +684,10 @@ const apiPage = (
+
+
+
+
);
@@ -673,6 +705,9 @@ const userPage = (
entityFilterKind={customEntityFilterKind}
/>
+
+
+
@@ -695,6 +730,9 @@ const groupPage = (
+
+
+
@@ -720,6 +758,11 @@ const systemPage = (
+
+
+
+
+
@@ -748,6 +791,9 @@ const systemPage = (
unidirectional={false}
/>
+
+
+
);
@@ -765,8 +811,16 @@ const domainPage = (
+
+
+
+
+
+
+
+
);
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3678c1f9ae..34a7498927 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -42,6 +42,7 @@
"@backstage/plugin-catalog-backend": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-code-coverage-backend": "workspace:^",
+ "@backstage/plugin-entity-feedback-backend": "workspace:^",
"@backstage/plugin-events-backend": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-explore-backend": "workspace:^",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index b68b162e52..495dc435c1 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -44,6 +44,7 @@ import azureDevOps from './plugins/azure-devops';
import catalog from './plugins/catalog';
import catalogEventBasedProviders from './plugins/catalogEventBasedProviders';
import codeCoverage from './plugins/codecoverage';
+import entityFeedback from './plugins/entityFeedback';
import events from './plugins/events';
import explore from './plugins/explore';
import kubernetes from './plugins/kubernetes';
@@ -147,6 +148,9 @@ async function main() {
);
const permissionEnv = useHotMemoize(module, () => createEnv('permission'));
const playlistEnv = useHotMemoize(module, () => createEnv('playlist'));
+ const entityFeedbackEnv = useHotMemoize(module, () =>
+ createEnv('entityFeedback'),
+ );
const eventsEnv = useHotMemoize(module, () => createEnv('events'));
const exploreEnv = useHotMemoize(module, () => createEnv('explore'));
const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse'));
@@ -179,6 +183,7 @@ async function main() {
apiRouter.use('/permission', await permission(permissionEnv));
apiRouter.use('/playlist', await playlist(playlistEnv));
apiRouter.use('/explore', await explore(exploreEnv));
+ apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv));
apiRouter.use('/adr', await adr(adrEnv));
apiRouter.use(notFoundHandler());
diff --git a/packages/backend/src/plugins/entityFeedback.ts b/packages/backend/src/plugins/entityFeedback.ts
new file mode 100644
index 0000000000..c37c3c9a97
--- /dev/null
+++ b/packages/backend/src/plugins/entityFeedback.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createRouter } from '@backstage/plugin-entity-feedback-backend';
+import { Router } from 'express';
+import { PluginEnvironment } from '../types';
+
+export default function createPlugin(env: PluginEnvironment): Promise {
+ return createRouter({
+ database: env.database,
+ discovery: env.discovery,
+ identity: env.identity,
+ logger: env.logger,
+ });
+}
diff --git a/plugins/entity-feedback-backend/.eslintrc.js b/plugins/entity-feedback-backend/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/entity-feedback-backend/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/entity-feedback-backend/README.md b/plugins/entity-feedback-backend/README.md
new file mode 100644
index 0000000000..ef180dbae5
--- /dev/null
+++ b/plugins/entity-feedback-backend/README.md
@@ -0,0 +1,50 @@
+# Entity Feedback Backend
+
+Welcome to the entity-feedback backend plugin!
+
+## Installation
+
+### Install the package
+
+```bash
+# From your Backstage root directory
+yarn add --cwd packages/backend @backstage/plugin-entity-feedback-backend
+```
+
+### Adding the plugin to your `packages/backend`
+
+You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/entityFeedback.ts`
+
+```tsx
+import { createRouter } from '@backstage/plugin-entity-feedback-backend';
+import { Router } from 'express';
+import { PluginEnvironment } from '../types';
+
+export default function createPlugin(env: PluginEnvironment): Promise {
+ return createRouter({
+ database: env.database,
+ discovery: env.discovery,
+ identity: env.identity,
+ logger: env.logger,
+ });
+}
+```
+
+With the `entityFeedback.ts` router setup in place, add the router to `packages/backend/src/index.ts`:
+
+```diff
++import entityFeedback from './plugins/entityFeedback';
+
+async function main() {
+ ...
+ const createEnv = makeCreateEnv(config);
+
+ const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
++ const entityFeedbackEnv = useHotMemoize(module, () => createEnv('entityFeedback'));
+
+ const apiRouter = Router();
++ apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv));
+ ...
+ apiRouter.use(notFoundHandler());
+
+```
diff --git a/plugins/entity-feedback-backend/api-report.md b/plugins/entity-feedback-backend/api-report.md
new file mode 100644
index 0000000000..3909ac3056
--- /dev/null
+++ b/plugins/entity-feedback-backend/api-report.md
@@ -0,0 +1,26 @@
+## API Report File for "@backstage/plugin-entity-feedback-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import express from 'express';
+import { IdentityApi } from '@backstage/plugin-auth-node';
+import { Logger } from 'winston';
+import { PluginDatabaseManager } from '@backstage/backend-common';
+import { PluginEndpointDiscovery } from '@backstage/backend-common';
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ database: PluginDatabaseManager;
+ // (undocumented)
+ discovery: PluginEndpointDiscovery;
+ // (undocumented)
+ identity: IdentityApi;
+ // (undocumented)
+ logger: Logger;
+}
+```
diff --git a/plugins/entity-feedback-backend/migrations/20230109124329_init.js b/plugins/entity-feedback-backend/migrations/20230109124329_init.js
new file mode 100644
index 0000000000..42a48d264c
--- /dev/null
+++ b/plugins/entity-feedback-backend/migrations/20230109124329_init.js
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+/**
+ * @param { import("knex").Knex } knex
+ */
+exports.up = async function up(knex) {
+ await knex.schema.createTable('ratings', table => {
+ table.comment('Ratings table');
+ table
+ .string('entity_ref')
+ .notNullable()
+ .comment('The ref of the entity being rated');
+ table
+ .string('user_ref')
+ .notNullable()
+ .comment('The user applying the rating');
+ table.string('rating').notNullable().comment('The rating value');
+ table
+ .timestamp('timestamp')
+ .defaultTo(knex.fn.now())
+ .notNullable()
+ .comment('When the rating was recorded');
+ });
+
+ await knex.schema.createTable('responses', table => {
+ table.comment('Responses table');
+ table
+ .string('entity_ref')
+ .notNullable()
+ .comment('The ref of the applicable entity');
+ table.string('user_ref').notNullable().comment('The user responding');
+ table.text('response').comment('The serialized response');
+ table.text('comments').comment('Additional user comments');
+ table
+ .boolean('consent')
+ .defaultTo(true)
+ .notNullable()
+ .comment('Whether user (if recorded) consents to being contacted');
+ table
+ .timestamp('timestamp')
+ .defaultTo(knex.fn.now())
+ .notNullable()
+ .comment('When the response was recorded');
+ });
+};
+
+/**
+ * @param { import("knex").Knex } knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.dropTable('ratings');
+ await knex.schema.dropTable('responses');
+};
diff --git a/plugins/entity-feedback-backend/package.json b/plugins/entity-feedback-backend/package.json
new file mode 100644
index 0000000000..7fbc7b9f7e
--- /dev/null
+++ b/plugins/entity-feedback-backend/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@backstage/plugin-entity-feedback-backend",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "backend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "workspace:^",
+ "@backstage/catalog-client": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/plugin-auth-node": "workspace:^",
+ "@backstage/plugin-entity-feedback-common": "workspace:^",
+ "@types/express": "*",
+ "express": "^4.18.1",
+ "express-promise-router": "^4.1.0",
+ "knex": "^2.0.0",
+ "node-fetch": "^2.6.7",
+ "winston": "^3.2.1",
+ "yn": "^4.0.0"
+ },
+ "devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
+ "@backstage/cli": "workspace:^",
+ "@types/supertest": "^2.0.12",
+ "msw": "^0.49.0",
+ "supertest": "^6.2.4"
+ },
+ "files": [
+ "dist",
+ "migrations/**/*.{js,d.ts}"
+ ]
+}
diff --git a/plugins/entity-feedback-backend/src/index.ts b/plugins/entity-feedback-backend/src/index.ts
new file mode 100644
index 0000000000..b3c4139eed
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Entity Feedback backend plugin
+ *
+ * @packageDocumentation
+ */
+export * from './service/router';
diff --git a/plugins/entity-feedback-backend/src/run.ts b/plugins/entity-feedback-backend/src/run.ts
new file mode 100644
index 0000000000..7344f24c17
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/run.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { getRootLogger } from '@backstage/backend-common';
+import yn from 'yn';
+import { startStandaloneServer } from './service/standaloneServer';
+
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
+const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
+const logger = getRootLogger();
+
+startStandaloneServer({ port, enableCors, logger }).catch(err => {
+ logger.error(err);
+ process.exit(1);
+});
+
+process.on('SIGINT', () => {
+ logger.info('CTRL+C pressed; exiting.');
+ process.exit(0);
+});
diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts
new file mode 100644
index 0000000000..c6cde95b3c
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts
@@ -0,0 +1,310 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
+import { Knex } from 'knex';
+
+import { DatabaseHandler } from './DatabaseHandler';
+
+describe('DatabaseHandler', () => {
+ const databases = TestDatabases.create();
+
+ async function createDatabaseHandler(databaseId: TestDatabaseId) {
+ const knex = await databases.init(databaseId);
+ return {
+ knex,
+ dbHandler: await DatabaseHandler.create({ database: knex }),
+ };
+ }
+
+ describe.each(databases.eachSupportedId())(
+ '%p',
+ databaseId => {
+ let knex: Knex;
+ let dbHandler: DatabaseHandler;
+
+ beforeEach(async () => {
+ ({ knex, dbHandler } = await createDatabaseHandler(databaseId));
+ }, 30000);
+
+ afterEach(async () => {
+ // Clean up after each test
+ await knex('ratings').del();
+ await knex('responses').del();
+ }, 30000);
+
+ it('getAllRatedEntities', async () => {
+ await knex('ratings').insert([
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'DISLIKE',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ },
+ {
+ entity_ref: 'component:default/library',
+ user_ref: 'user:default/bar',
+ rating: 'LIKE',
+ },
+ {
+ entity_ref: 'component:default/website',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ },
+ ]);
+
+ const entities = await dbHandler.getAllRatedEntities();
+ expect(entities.length).toEqual(3);
+ expect(entities).toEqual(
+ expect.arrayContaining([
+ 'component:default/service',
+ 'component:default/library',
+ 'component:default/website',
+ ]),
+ );
+ });
+
+ it('getRatingsAggregates', async () => {
+ await knex('ratings').insert([
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'DISLIKE',
+ timestamp: '2004-10-19 10:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ timestamp: '2004-11-19 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/bar',
+ rating: 'LIKE',
+ timestamp: '2004-11-20 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/website',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ timestamp: '2004-11-20 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/website',
+ user_ref: 'user:default/test',
+ rating: 'DISLIKE',
+ timestamp: '2004-11-20 08:23:54',
+ },
+ ]);
+
+ let ratings = await dbHandler.getRatingsAggregates([
+ 'component:default/service',
+ 'component:default/website',
+ ]);
+ expect(ratings.length).toEqual(3);
+ expect(ratings).toEqual(
+ expect.arrayContaining([
+ {
+ entityRef: 'component:default/service',
+ rating: 'LIKE',
+ count: 2,
+ },
+ {
+ entityRef: 'component:default/website',
+ rating: 'DISLIKE',
+ count: 1,
+ },
+ {
+ entityRef: 'component:default/website',
+ rating: 'LIKE',
+ count: 1,
+ },
+ ]),
+ );
+
+ ratings = await dbHandler.getRatingsAggregates([
+ 'component:default/website',
+ ]);
+ expect(ratings.length).toEqual(2);
+ expect(ratings).toEqual(
+ expect.arrayContaining([
+ {
+ entityRef: 'component:default/website',
+ rating: 'DISLIKE',
+ count: 1,
+ },
+ {
+ entityRef: 'component:default/website',
+ rating: 'LIKE',
+ count: 1,
+ },
+ ]),
+ );
+ });
+
+ it('recordRating', async () => {
+ const newRating = {
+ userRef: 'user:default/me',
+ entityRef: 'component:default/service',
+ rating: 'LIKE',
+ };
+
+ await dbHandler.recordRating(newRating);
+
+ const ratings = await knex('ratings').select();
+ expect(ratings.length).toEqual(1);
+ expect(ratings[0]).toEqual(
+ expect.objectContaining({
+ user_ref: newRating.userRef,
+ entity_ref: newRating.entityRef,
+ rating: newRating.rating,
+ timestamp: expect.anything(),
+ }),
+ );
+ });
+
+ it('getRatings', async () => {
+ await knex('ratings').insert([
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'DISLIKE',
+ timestamp: '2004-10-19 10:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ timestamp: '2004-11-19 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/bar',
+ rating: 'LIKE',
+ timestamp: '2004-11-20 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/website',
+ user_ref: 'user:default/foo',
+ rating: 'LIKE',
+ timestamp: '2004-11-20 08:23:54',
+ },
+ ]);
+
+ const ratings = await dbHandler.getRatings('component:default/service');
+ expect(ratings.length).toEqual(2);
+ expect(ratings).toEqual(
+ expect.arrayContaining([
+ { userRef: 'user:default/foo', rating: 'LIKE' },
+ { userRef: 'user:default/bar', rating: 'LIKE' },
+ ]),
+ );
+ });
+
+ it('recordResponse', async () => {
+ const newResponse = {
+ userRef: 'user:default/me',
+ entityRef: 'component:default/service',
+ response: 'blah',
+ comments: 'here is some feedback',
+ consent: false,
+ };
+
+ await dbHandler.recordResponse(newResponse);
+
+ const responses = await knex('responses').select();
+ expect(responses.length).toEqual(1);
+ expect({
+ ...responses[0],
+ consent: Boolean(responses[0].consent),
+ }).toEqual(
+ expect.objectContaining({
+ user_ref: newResponse.userRef,
+ entity_ref: newResponse.entityRef,
+ response: newResponse.response,
+ comments: newResponse.comments,
+ consent: newResponse.consent,
+ timestamp: expect.anything(),
+ }),
+ );
+ });
+
+ it('getResponses', async () => {
+ await knex('responses').insert([
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ response: 'blah',
+ comments: 'here is some feedback',
+ consent: true,
+ timestamp: '2004-10-19 10:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/foo',
+ response: 'asdf',
+ comments: 'here is new feedback',
+ consent: false,
+ timestamp: '2004-11-19 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/service',
+ user_ref: 'user:default/bar',
+ response: 'noop',
+ comments: 'here is different feedback',
+ consent: true,
+ timestamp: '2004-11-20 08:23:54',
+ },
+ {
+ entity_ref: 'component:default/website',
+ user_ref: 'user:default/foo',
+ response: 'cool',
+ comments: 'no comment',
+ consent: true,
+ timestamp: '2004-11-20 08:23:54',
+ },
+ ]);
+
+ const responses = await dbHandler.getResponses(
+ 'component:default/service',
+ );
+ expect(responses.length).toEqual(2);
+ expect(responses).toEqual(
+ expect.arrayContaining([
+ {
+ userRef: 'user:default/foo',
+ response: 'asdf',
+ comments: 'here is new feedback',
+ consent: false,
+ },
+ {
+ userRef: 'user:default/bar',
+ response: 'noop',
+ comments: 'here is different feedback',
+ consent: true,
+ },
+ ]),
+ );
+ });
+ },
+ 60000,
+ );
+});
diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts
new file mode 100644
index 0000000000..9b81cd94bb
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { resolvePackagePath } from '@backstage/backend-common';
+import {
+ FeedbackResponse,
+ Rating,
+} from '@backstage/plugin-entity-feedback-common';
+import { Knex } from 'knex';
+
+const migrationsDir = resolvePackagePath(
+ '@backstage/plugin-entity-feedback-backend',
+ 'migrations',
+);
+
+type RatingsAggregatesResult = {
+ entityRef: string;
+ rating: string;
+ count: number;
+}[];
+
+type Options = {
+ database: Knex;
+};
+
+/**
+ * @public
+ */
+export class DatabaseHandler {
+ static async create(options: Options): Promise {
+ const { database } = options;
+
+ await database.migrate.latest({
+ directory: migrationsDir,
+ });
+
+ return new DatabaseHandler(options);
+ }
+
+ private readonly database: Knex;
+
+ private constructor(options: Options) {
+ this.database = options.database;
+ }
+
+ async getAllRatedEntities(): Promise {
+ return (await this.database('ratings').distinct('entity_ref')).map(
+ ({ entity_ref }) => entity_ref,
+ );
+ }
+
+ async getRatingsAggregates(
+ entityRefs: string[],
+ ): Promise {
+ return (
+ await this.database('ratings')
+ .whereIn('ratings.entity_ref', entityRefs)
+ .innerJoin(
+ this.database('ratings')
+ .as('latest_ratings')
+ .select('entity_ref', 'user_ref')
+ .max('timestamp', { as: 'timestamp' })
+ .whereIn('entity_ref', entityRefs)
+ .groupBy('entity_ref', 'user_ref'),
+ function joinClause() {
+ this.on('ratings.entity_ref', '=', 'latest_ratings.entity_ref')
+ .andOn('ratings.user_ref', '=', 'latest_ratings.user_ref')
+ .andOn('ratings.timestamp', '=', 'latest_ratings.timestamp');
+ },
+ )
+ .select('ratings.entity_ref', 'rating')
+ .count('ratings.user_ref as count')
+ .groupBy('ratings.entity_ref', 'rating')
+ ).map(({ entity_ref, rating, count }) => ({
+ entityRef: entity_ref as string,
+ rating: rating as string,
+ count: Number(count),
+ }));
+ }
+
+ async recordRating(rating: Rating) {
+ await this.database('ratings').insert({
+ entity_ref: rating.entityRef,
+ rating: rating.rating,
+ user_ref: rating.userRef,
+ });
+ }
+
+ async getRatings(entityRef: string): Promise[]> {
+ return (
+ await this.database('ratings')
+ .where('entity_ref', entityRef)
+ .innerJoin(
+ this.database('ratings')
+ .as('latest_ratings')
+ .select('user_ref')
+ .max('timestamp', { as: 'timestamp' })
+ .where('entity_ref', entityRef)
+ .groupBy('user_ref'),
+ function joinClause() {
+ this.on('ratings.user_ref', '=', 'latest_ratings.user_ref').andOn(
+ 'ratings.timestamp',
+ '=',
+ 'latest_ratings.timestamp',
+ );
+ },
+ )
+ .select('ratings.user_ref', 'rating')
+ ).map(rating => ({ userRef: rating.user_ref, rating: rating.rating }));
+ }
+
+ async recordResponse(response: FeedbackResponse) {
+ await this.database('responses').insert({
+ entity_ref: response.entityRef,
+ response: response.response,
+ comments: response.comments,
+ consent: response.consent,
+ user_ref: response.userRef,
+ });
+ }
+
+ async getResponses(
+ entityRef: string,
+ ): Promise[]> {
+ return (
+ await this.database('responses')
+ .where('entity_ref', entityRef)
+ .innerJoin(
+ this.database('responses')
+ .as('latest_responses')
+ .select('user_ref')
+ .max('timestamp', { as: 'timestamp' })
+ .where('entity_ref', entityRef)
+ .groupBy('user_ref'),
+ function joinClause() {
+ this.on(
+ 'responses.user_ref',
+ '=',
+ 'latest_responses.user_ref',
+ ).andOn('responses.timestamp', '=', 'latest_responses.timestamp');
+ },
+ )
+ .select('responses.user_ref', 'response', 'comments', 'consent')
+ ).map(response => ({
+ userRef: response.user_ref,
+ response: response.response,
+ comments: response.comments,
+ consent: Boolean(response.consent),
+ }));
+ }
+}
diff --git a/plugins/entity-feedback-backend/src/service/router.test.ts b/plugins/entity-feedback-backend/src/service/router.test.ts
new file mode 100644
index 0000000000..74fae8248b
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/router.test.ts
@@ -0,0 +1,290 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ DatabaseManager,
+ getVoidLogger,
+ PluginEndpointDiscovery,
+} from '@backstage/backend-common';
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { ConfigReader } from '@backstage/config';
+import { IdentityApi } from '@backstage/plugin-auth-node';
+import express from 'express';
+import request from 'supertest';
+
+import { createRouter } from './router';
+
+const sampleOwnedEntities = [
+ {
+ kind: 'component',
+ metadata: {
+ namespace: 'default',
+ name: 'foo',
+ title: 'Foo Component',
+ },
+ },
+ {
+ kind: 'component',
+ metadata: {
+ namespace: 'default',
+ name: 'bar',
+ title: 'Bar Component',
+ },
+ },
+];
+
+const sampleEntities = [
+ ...sampleOwnedEntities,
+ {
+ kind: 'user',
+ metadata: {
+ namespace: 'default',
+ name: 'foo',
+ },
+ },
+ null,
+ {
+ kind: 'user',
+ metadata: {
+ namespace: 'default',
+ name: 'bar',
+ },
+ },
+];
+
+const mockGetEntties = jest
+ .fn()
+ .mockImplementation(async () => ({ items: sampleOwnedEntities }));
+
+const mockGetEnttiesByRefs = jest
+ .fn()
+ .mockImplementation(async () => ({ items: sampleEntities }));
+
+jest.mock('@backstage/catalog-client', () => ({
+ CatalogClient: jest.fn().mockImplementation(() => ({
+ getEntities: mockGetEntties,
+ getEntitiesByRefs: mockGetEnttiesByRefs,
+ })),
+}));
+
+jest.mock('@backstage/plugin-auth-node', () => ({
+ getBearerTokenFromAuthorizationHeader: () => 'token',
+}));
+
+const mockRatings = [
+ { userRef: 'user:default/foo', rating: 'LIKE' },
+ { userRef: 'user:default/bar', rating: 'LIKE' },
+ { userRef: 'user:default/test', rating: 'DISLIKE' },
+];
+
+const mockResponses = [
+ {
+ userRef: 'user:default/foo',
+ response: 'asdf',
+ comments: 'here is new feedback',
+ consent: false,
+ },
+ {
+ userRef: 'user:default/bar',
+ response: 'noop',
+ comments: 'here is different feedback',
+ consent: true,
+ },
+ {
+ userRef: 'user:default/test',
+ response: 'err',
+ comments: 'no comment',
+ consent: false,
+ },
+];
+
+const mockDbHandler = {
+ getAllRatedEntities: jest
+ .fn()
+ .mockImplementation(async () => [
+ 'component:default/foo',
+ 'component:default/bar',
+ 'component:default/test',
+ ]),
+ getRatingsAggregates: jest.fn().mockImplementation(async () => [
+ { entityRef: 'component:default/foo', rating: 'LIKE', count: 3 },
+ { entityRef: 'component:default/foo', rating: 'DISLIKE', count: 1 },
+ { entityRef: 'component:default/bar', rating: 'LIKE', count: 5 },
+ ]),
+ recordRating: jest.fn().mockImplementation(async () => {}),
+ getRatings: jest.fn().mockImplementation(async () => mockRatings),
+ recordResponse: jest.fn().mockImplementation(async () => {}),
+ getResponses: jest.fn().mockImplementation(async () => mockResponses),
+};
+
+jest.mock('./DatabaseHandler', () => ({
+ DatabaseHandler: { create: async () => mockDbHandler },
+}));
+
+describe('createRouter', () => {
+ let app: express.Express;
+
+ const createDatabase = () =>
+ DatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'better-sqlite3',
+ connection: ':memory:',
+ },
+ },
+ }),
+ ).forPlugin('entity-feedback');
+
+ const mockIdentityClient = {
+ getIdentity: jest.fn().mockImplementation(async () => ({
+ identity: { userEntityRef: 'user:default/me' },
+ })),
+ } as unknown as IdentityApi;
+
+ const discovery: jest.Mocked = {
+ getBaseUrl: jest.fn(),
+ getExternalBaseUrl: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ const router = await createRouter({
+ database: createDatabase(),
+ discovery,
+ identity: mockIdentityClient,
+ logger: getVoidLogger(),
+ });
+
+ app = express().use(router);
+ jest.clearAllMocks();
+ });
+
+ describe('GET /ratings', () => {
+ it('should get ratings for all entities correctly', async () => {
+ const response = await request(app).get('/ratings').send();
+
+ expect(mockDbHandler.getAllRatedEntities).toHaveBeenCalled();
+ expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
+ sampleEntities
+ .filter(Boolean)
+ .map((ent: any) => stringifyEntityRef(ent)),
+ );
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual([
+ {
+ entityRef: 'component:default/foo',
+ entityTitle: 'Foo Component',
+ ratings: { LIKE: 3, DISLIKE: 1 },
+ },
+ {
+ entityRef: 'component:default/bar',
+ entityTitle: 'Bar Component',
+ ratings: { LIKE: 5 },
+ },
+ ]);
+ });
+
+ it('should get ratings for all owned entities correctly', async () => {
+ const response = await request(app)
+ .get('/ratings?ownerRef=group:default/test-team')
+ .send();
+
+ expect(mockGetEntties).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filter: { 'relations.ownedBy': 'group:default/test-team' },
+ }),
+ { token: 'token' },
+ );
+ expect(mockDbHandler.getAllRatedEntities).not.toHaveBeenCalled();
+ expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith(
+ sampleOwnedEntities.map((ent: any) => stringifyEntityRef(ent)),
+ );
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual([
+ {
+ entityRef: 'component:default/foo',
+ entityTitle: 'Foo Component',
+ ratings: { LIKE: 3, DISLIKE: 1 },
+ },
+ {
+ entityRef: 'component:default/bar',
+ entityTitle: 'Bar Component',
+ ratings: { LIKE: 5 },
+ },
+ ]);
+ });
+ });
+
+ describe('POST /ratings/:entityRef', () => {
+ it('should record a rating correctly', async () => {
+ const body = { rating: 'LIKE' };
+ const response = await request(app)
+ .post('/ratings/component%3Adefault%2Fservice')
+ .send(body);
+ expect(mockDbHandler.recordRating).toHaveBeenCalledWith({
+ entityRef: 'component:default/service',
+ userRef: 'user:default/me',
+ ...body,
+ });
+ expect(response.status).toEqual(201);
+ });
+ });
+
+ describe('GET /ratings/:entityRef', () => {
+ it('should get ratings for an entity correctly', async () => {
+ const response = await request(app)
+ .get('/ratings/component%3Adefault%2Fservice')
+ .send();
+ expect(mockDbHandler.getRatings).toHaveBeenCalledWith(
+ 'component:default/service',
+ );
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual(
+ mockRatings.filter(r => r.userRef !== 'user:default/test'),
+ );
+ });
+ });
+
+ describe('POST /responses/:entityRef', () => {
+ it('should record a response correctly', async () => {
+ const body = { response: 'blah', comments: 'feedback', consent: true };
+ const response = await request(app)
+ .post('/responses/component%3Adefault%2Fservice')
+ .send(body);
+ expect(mockDbHandler.recordResponse).toHaveBeenCalledWith({
+ entityRef: 'component:default/service',
+ userRef: 'user:default/me',
+ ...body,
+ });
+ expect(response.status).toEqual(201);
+ });
+ });
+
+ describe('GET /responses/:entityRef', () => {
+ it('should get responses for an entity correctly', async () => {
+ const response = await request(app)
+ .get('/responses/component%3Adefault%2Fservice')
+ .send();
+ expect(mockDbHandler.getResponses).toHaveBeenCalledWith(
+ 'component:default/service',
+ );
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual(
+ mockResponses.filter(r => r.userRef !== 'user:default/test'),
+ );
+ });
+ });
+});
diff --git a/plugins/entity-feedback-backend/src/service/router.ts b/plugins/entity-feedback-backend/src/service/router.ts
new file mode 100644
index 0000000000..e1243cedaf
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/router.ts
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ errorHandler,
+ PluginDatabaseManager,
+ PluginEndpointDiscovery,
+} from '@backstage/backend-common';
+import { CatalogClient } from '@backstage/catalog-client';
+import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
+import {
+ getBearerTokenFromAuthorizationHeader,
+ IdentityApi,
+} from '@backstage/plugin-auth-node';
+import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
+import express from 'express';
+import Router from 'express-promise-router';
+import { Logger } from 'winston';
+
+import { DatabaseHandler } from './DatabaseHandler';
+
+/**
+ * @public
+ */
+export interface RouterOptions {
+ database: PluginDatabaseManager;
+ discovery: PluginEndpointDiscovery;
+ identity: IdentityApi;
+ logger: Logger;
+}
+
+/**
+ * @public
+ */
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const { database, discovery, identity, logger } = options;
+
+ logger.info('Initializing Entity Feedback backend');
+
+ const catalogClient = new CatalogClient({ discoveryApi: discovery });
+ const db = await database.getClient();
+ const dbHandler = await DatabaseHandler.create({ database: db });
+
+ const router = Router();
+ router.use(express.json());
+
+ router.get('/ratings', async (req, res) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+
+ const requestedEntities: { [ref: string]: Entity } = {};
+ if (req.query.ownerRef) {
+ // Get ratings from all owned entities (also ensures only accessible entities are requested)
+ (
+ await catalogClient.getEntities(
+ {
+ filter: { 'relations.ownedBy': req.query.ownerRef as string },
+ fields: [
+ 'kind',
+ 'metadata.name',
+ 'metadata.namespace',
+ 'metadata.title',
+ ],
+ },
+ { token },
+ )
+ ).items.forEach(ent => {
+ requestedEntities[stringifyEntityRef(ent)] = ent;
+ });
+ } else {
+ const allRatedEntities = await dbHandler.getAllRatedEntities();
+
+ // Filter entities to only expose entity refs accessible by current user
+ (
+ await catalogClient.getEntitiesByRefs(
+ {
+ entityRefs: allRatedEntities,
+ fields: [
+ 'kind',
+ 'metadata.namespace',
+ 'metadata.name',
+ 'metadata.title',
+ ],
+ },
+ { token },
+ )
+ ).items
+ .filter(Boolean)
+ .forEach(ent => {
+ requestedEntities[stringifyEntityRef(ent!)] = ent!;
+ });
+ }
+
+ const entityRatings = await dbHandler.getRatingsAggregates(
+ Object.keys(requestedEntities),
+ );
+
+ // Merge rating aggregates into a condensed per entity structure
+ const entityRatingsMap = entityRatings.reduce<{
+ [ref: string]: EntityRatingsData;
+ }>((ratingsMap, { entityRef, rating, count }) => {
+ ratingsMap[entityRef] = ratingsMap[entityRef] ?? {
+ entityRef,
+ entityTitle: requestedEntities[entityRef].metadata.title,
+ ratings: {},
+ };
+ ratingsMap[entityRef].ratings[rating] = count;
+ return ratingsMap;
+ }, {});
+
+ res.json(Object.values(entityRatingsMap));
+ });
+
+ router.post('/ratings/:entityRef', async (req, res) => {
+ const user = await identity.getIdentity({ request: req });
+ const rating = req.body.rating;
+ if (!user || !rating) {
+ res.status(400).end();
+ return;
+ }
+
+ await dbHandler.recordRating({
+ entityRef: req.params.entityRef,
+ rating,
+ userRef: user.identity.userEntityRef,
+ });
+
+ res.status(201).end();
+ });
+
+ router.get('/ratings/:entityRef', async (req, res) => {
+ const ratings = await dbHandler.getRatings(req.params.entityRef);
+
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+
+ // Filter ratings via user refs to only expose entity refs accessible by current user
+ const accessibleEntityRefs = (
+ await catalogClient.getEntitiesByRefs(
+ {
+ entityRefs: ratings.map(r => r.userRef),
+ fields: ['kind', 'metadata.namespace', 'metadata.name'],
+ },
+ { token },
+ )
+ ).items
+ .filter(Boolean)
+ .map(ent => stringifyEntityRef(ent!));
+
+ res.json(ratings.filter(r => accessibleEntityRefs.includes(r.userRef)));
+ });
+
+ router.post('/responses/:entityRef', async (req, res) => {
+ const user = await identity.getIdentity({ request: req });
+ const { response, comments, consent } = req.body;
+
+ if (!user) {
+ res.status(400).end();
+ return;
+ }
+
+ await dbHandler.recordResponse({
+ entityRef: req.params.entityRef,
+ response,
+ comments,
+ consent,
+ userRef: user.identity.userEntityRef,
+ });
+
+ res.status(201).end();
+ });
+
+ router.get('/responses/:entityRef', async (req, res) => {
+ const responses = await dbHandler.getResponses(req.params.entityRef);
+
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+
+ // Filter responses via user refs to only expose entity refs accessible by current user
+ const accessibleEntityRefs = (
+ await catalogClient.getEntitiesByRefs(
+ {
+ entityRefs: responses.map(r => r.userRef),
+ fields: ['kind', 'metadata.namespace', 'metadata.name'],
+ },
+ { token },
+ )
+ ).items
+ .filter(Boolean)
+ .map(ent => stringifyEntityRef(ent!));
+
+ res.json(responses.filter(r => accessibleEntityRefs.includes(r.userRef)));
+ });
+
+ router.use(errorHandler());
+ return router;
+}
diff --git a/plugins/entity-feedback-backend/src/service/standaloneServer.ts b/plugins/entity-feedback-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..9395c72141
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/standaloneServer.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createServiceBuilder,
+ DatabaseManager,
+ loadBackendConfig,
+ SingleHostDiscovery,
+ useHotMemoize,
+} from '@backstage/backend-common';
+import { ConfigReader } from '@backstage/config';
+import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
+import { Server } from 'http';
+import { Logger } from 'winston';
+import { createRouter } from './router';
+
+export interface ServerOptions {
+ port: number;
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'entity-feedback-backend' });
+ const config = await loadBackendConfig({ logger, argv: process.argv });
+ const discovery = SingleHostDiscovery.fromConfig(config);
+
+ const database = useHotMemoize(module, () => {
+ const manager = DatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: { client: 'better-sqlite3', connection: ':memory:' },
+ },
+ }),
+ );
+ return manager.forPlugin('entity-feedback');
+ });
+
+ const identity = DefaultIdentityClient.create({
+ discovery,
+ issuer: await discovery.getExternalBaseUrl('auth'),
+ });
+
+ logger.debug('Starting application server...');
+ const router = await createRouter({
+ database,
+ discovery,
+ identity,
+ logger,
+ });
+
+ let service = createServiceBuilder(module)
+ .setPort(options.port)
+ .addRouter('/entity-feedback', router);
+ if (options.enableCors) {
+ service = service.enableCors({ origin: 'http://localhost:3000' });
+ }
+
+ return service.start().catch(err => {
+ logger.error(err);
+ process.exit(1);
+ });
+}
+
+module.hot?.accept();
diff --git a/plugins/entity-feedback-backend/src/setupTests.ts b/plugins/entity-feedback-backend/src/setupTests.ts
new file mode 100644
index 0000000000..aa70772592
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {};
diff --git a/plugins/entity-feedback-common/.eslintrc.js b/plugins/entity-feedback-common/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/entity-feedback-common/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/entity-feedback-common/README.md b/plugins/entity-feedback-common/README.md
new file mode 100644
index 0000000000..430d14aeab
--- /dev/null
+++ b/plugins/entity-feedback-common/README.md
@@ -0,0 +1,3 @@
+# Entity Feedback Common
+
+Common types for the entity-feedback plugin.
diff --git a/plugins/entity-feedback-common/api-report.md b/plugins/entity-feedback-common/api-report.md
new file mode 100644
index 0000000000..118e6c78f2
--- /dev/null
+++ b/plugins/entity-feedback-common/api-report.md
@@ -0,0 +1,41 @@
+## API Report File for "@backstage/plugin-entity-feedback-common"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+// @public (undocumented)
+export interface EntityRatingsData {
+ // (undocumented)
+ entityRef: string;
+ // (undocumented)
+ entityTitle?: string;
+ // (undocumented)
+ ratings: {
+ [ratingValue: string]: number;
+ };
+}
+
+// @public (undocumented)
+export interface FeedbackResponse {
+ // (undocumented)
+ comments?: string;
+ // (undocumented)
+ consent?: boolean;
+ // (undocumented)
+ entityRef: string;
+ // (undocumented)
+ response?: string;
+ // (undocumented)
+ userRef: string;
+}
+
+// @public (undocumented)
+export interface Rating {
+ // (undocumented)
+ entityRef: string;
+ // (undocumented)
+ rating: string;
+ // (undocumented)
+ userRef: string;
+}
+```
diff --git a/plugins/entity-feedback-common/package.json b/plugins/entity-feedback-common/package.json
new file mode 100644
index 0000000000..6316b72943
--- /dev/null
+++ b/plugins/entity-feedback-common/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@backstage/plugin-entity-feedback-common",
+ "description": "Common functionalities for the entity-feedback plugin",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "common-library"
+ },
+ "scripts": {
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/entity-feedback-common/src/index.ts b/plugins/entity-feedback-common/src/index.ts
new file mode 100644
index 0000000000..eea289a899
--- /dev/null
+++ b/plugins/entity-feedback-common/src/index.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Common functionalities for the entity-feedback plugin.
+ *
+ * @packageDocumentation
+ */
+
+/**
+ * @public
+ */
+export interface Rating {
+ entityRef: string;
+ rating: string;
+ userRef: string;
+}
+
+/**
+ * @public
+ */
+export interface FeedbackResponse {
+ entityRef: string;
+ response?: string;
+ comments?: string;
+ consent?: boolean;
+ userRef: string;
+}
+
+/**
+ * @public
+ */
+export interface EntityRatingsData {
+ entityRef: string;
+ entityTitle?: string;
+ ratings: {
+ [ratingValue: string]: number;
+ };
+}
diff --git a/plugins/entity-feedback-common/src/setupTests.ts b/plugins/entity-feedback-common/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/plugins/entity-feedback-common/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export {};
diff --git a/plugins/entity-feedback/.eslintrc.js b/plugins/entity-feedback/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/entity-feedback/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/entity-feedback/README.md b/plugins/entity-feedback/README.md
new file mode 100644
index 0000000000..fdfa4761d6
--- /dev/null
+++ b/plugins/entity-feedback/README.md
@@ -0,0 +1,136 @@
+# Entity Feedback Plugin
+
+Welcome to the entity-feedback plugin!
+
+This plugin allows you give and view feedback on entities available in the Backstage catalog.
+
+## Features
+
+### Rate entities
+
+#### Like/Dislike rating
+
+
+
+#### Starred rating
+
+
+
+### Request additional feedback when poorly rated
+
+
+
+### View entity feedback responses
+
+
+
+### View aggregated ratings on owned entities
+
+#### Total likes/dislikes
+
+
+
+#### Star breakdowns
+
+
+
+## Setup
+
+The following sections will help you get the Entity Feedback plugin setup and running
+
+### Backend
+
+You need to setup the [Entity Feedback backend plugin](https://github.com/backstage/backstage/tree/master/plugins/entity-feedback-backend) before you move forward with any of these steps if you haven't already
+
+### Installation
+
+Install this plugin:
+
+```bash
+# From your Backstage root directory
+yarn --cwd packages/app add @backstage/plugin-entity-feedback
+```
+
+### Entity Pages
+
+Add rating and feedback components to your `EntityPage.tsx` to hook up UI so that users
+can rate your entities and for owners to view feedback/responses.
+
+To allow users to apply "Like" and "Dislike" ratings add the following to each kind/type of
+entity in your `EntityPage.tsx` you want to be rated (if you prefer to use star ratings, replace
+`EntityLikeDislikeRatingsCard` with `EntityStarredRatingsCard` and `LikeDislikeButtons` with
+`StarredRatingButtons`):
+
+```diff
+import {
+ ...
++ InfoCard,
+ ...
+} from '@backstage/core-components';
++import {
++ EntityFeedbackResponseContent,
++ EntityLikeDislikeRatingsCard,
++ LikeDislikeButtons,
++} from '@backstage/plugin-entity-feedback';
+
+// Add to each applicable kind/type of entity as desired
+const overviewContent = (
+
+ ...
++
++
++
++
++
+ ...
+
+);
+
+...
+
+// Add to each applicable kind/type of entity as desired
+const serviceEntityPage = (
+
+ ...
++
++
++
+ ...
+
+);
+
+...
+
+// Add ratings card component to user/group entities to view ratings of owned entities
+const userPage = (
+
+
+
+ ...
++
++
++
+ ...
+
+
+
+
+);
+
+const groupPage = (
+
+
+
+ ...
++
++
++
+ ...
+
+
+
+
+);
+```
+
+Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx)
diff --git a/plugins/entity-feedback/api-report.md b/plugins/entity-feedback/api-report.md
new file mode 100644
index 0000000000..06b0166b0d
--- /dev/null
+++ b/plugins/entity-feedback/api-report.md
@@ -0,0 +1,184 @@
+## API Report File for "@backstage/plugin-entity-feedback"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { ApiRef } from '@backstage/core-plugin-api';
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { DiscoveryApi } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
+import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common';
+import { FetchApi } from '@backstage/core-plugin-api';
+import { Rating } from '@backstage/plugin-entity-feedback-common';
+import { ReactNode } from 'react';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// @public (undocumented)
+export interface EntityFeedbackApi {
+ // (undocumented)
+ getAllRatings(): Promise;
+ // (undocumented)
+ getOwnedRatings(ownerRef: string): Promise;
+ // (undocumented)
+ getRatings(entityRef: string): Promise[]>;
+ // (undocumented)
+ getResponses(
+ entityRef: string,
+ ): Promise[]>;
+ // (undocumented)
+ recordRating(entityRef: string, rating: string): Promise;
+ // (undocumented)
+ recordResponse(
+ entityRef: string,
+ response: Omit,
+ ): Promise;
+}
+
+// @public (undocumented)
+export const entityFeedbackApiRef: ApiRef;
+
+// @public (undocumented)
+export class EntityFeedbackClient implements EntityFeedbackApi {
+ constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
+ // (undocumented)
+ getAllRatings(): Promise;
+ // (undocumented)
+ getOwnedRatings(ownerRef: string): Promise;
+ // (undocumented)
+ getRatings(entityRef: string): Promise[]>;
+ // (undocumented)
+ getResponses(
+ entityRef: string,
+ ): Promise[]>;
+ // (undocumented)
+ recordRating(entityRef: string, rating: string): Promise;
+ // (undocumented)
+ recordResponse(
+ entityRef: string,
+ response: Omit,
+ ): Promise;
+}
+
+// @public (undocumented)
+export const entityFeedbackPlugin: BackstagePlugin<
+ {
+ root: RouteRef;
+ },
+ {},
+ {}
+>;
+
+// @public (undocumented)
+export interface EntityFeedbackResponse {
+ // (undocumented)
+ id: string;
+ // (undocumented)
+ label: string;
+}
+
+// @public (undocumented)
+export const EntityFeedbackResponseContent: () => JSX.Element;
+
+// @public (undocumented)
+export const EntityLikeDislikeRatingsCard: () => JSX.Element;
+
+// @public (undocumented)
+export const EntityStarredRatingsCard: () => JSX.Element;
+
+// @public (undocumented)
+export const FeedbackResponseDialog: (
+ props: FeedbackResponseDialogProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface FeedbackResponseDialogProps {
+ // (undocumented)
+ entity: Entity;
+ // (undocumented)
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ // (undocumented)
+ feedbackDialogTitle?: ReactNode;
+ // (undocumented)
+ onClose: () => void;
+ // (undocumented)
+ open: boolean;
+}
+
+// @public (undocumented)
+export const FeedbackResponseTable: (
+ props: FeedbackResponseTableProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface FeedbackResponseTableProps {
+ // (undocumented)
+ entityRef: string;
+ // (undocumented)
+ title?: string;
+}
+
+// @public (undocumented)
+export const LikeDislikeButtons: (
+ props: LikeDislikeButtonsProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface LikeDislikeButtonsProps {
+ // (undocumented)
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ // (undocumented)
+ feedbackDialogTitle?: ReactNode;
+ // (undocumented)
+ requestResponse?: boolean;
+}
+
+// @public (undocumented)
+export const LikeDislikeRatingsTable: (
+ props: LikeDislikeRatingsTableProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface LikeDislikeRatingsTableProps {
+ // (undocumented)
+ allEntities?: boolean;
+ // (undocumented)
+ ownerRef?: string;
+ // (undocumented)
+ title?: string;
+}
+
+// @public (undocumented)
+export const StarredRatingButtons: (
+ props: StarredRatingButtonsProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface StarredRatingButtonsProps {
+ // (undocumented)
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ // (undocumented)
+ feedbackDialogTitle?: ReactNode;
+ // (undocumented)
+ requestResponse?: boolean;
+ // (undocumented)
+ requestResponseThreshold?: number;
+}
+
+// @public (undocumented)
+export const StarredRatingsTable: (
+ props: StarredRatingsTableProps,
+) => JSX.Element;
+
+// @public (undocumented)
+export interface StarredRatingsTableProps {
+ // (undocumented)
+ allEntities?: boolean;
+ // (undocumented)
+ ownerRef?: string;
+ // (undocumented)
+ title?: string;
+}
+```
diff --git a/plugins/entity-feedback/docs/feedback-response-dialog.png b/plugins/entity-feedback/docs/feedback-response-dialog.png
new file mode 100644
index 0000000000..74270c805a
Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-dialog.png differ
diff --git a/plugins/entity-feedback/docs/feedback-response-table.png b/plugins/entity-feedback/docs/feedback-response-table.png
new file mode 100644
index 0000000000..3795ac809d
Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-table.png differ
diff --git a/plugins/entity-feedback/docs/like-dislike-rating.png b/plugins/entity-feedback/docs/like-dislike-rating.png
new file mode 100644
index 0000000000..0267218243
Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-rating.png differ
diff --git a/plugins/entity-feedback/docs/like-dislike-table.png b/plugins/entity-feedback/docs/like-dislike-table.png
new file mode 100644
index 0000000000..df38883d25
Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-table.png differ
diff --git a/plugins/entity-feedback/docs/starred-rating-table.png b/plugins/entity-feedback/docs/starred-rating-table.png
new file mode 100644
index 0000000000..e678c5b68a
Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating-table.png differ
diff --git a/plugins/entity-feedback/docs/starred-rating.png b/plugins/entity-feedback/docs/starred-rating.png
new file mode 100644
index 0000000000..6198f7ae66
Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating.png differ
diff --git a/plugins/entity-feedback/package.json b/plugins/entity-feedback/package.json
new file mode 100644
index 0000000000..f8560a9237
--- /dev/null
+++ b/plugins/entity-feedback/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@backstage/plugin-entity-feedback",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/entity-feedback"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-catalog-react": "workspace:^",
+ "@backstage/plugin-entity-feedback-common": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "react-use": "^17.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.13.1 || ^17.0.0",
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/react-hooks": "^8.0.1",
+ "@testing-library/user-event": "^14.0.0",
+ "@types/node": "*",
+ "cross-fetch": "^3.1.5",
+ "msw": "^0.49.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/entity-feedback/src/api/EntityFeedbackApi.ts b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts
new file mode 100644
index 0000000000..d2f1a46f55
--- /dev/null
+++ b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createApiRef } from '@backstage/core-plugin-api';
+import {
+ EntityRatingsData,
+ FeedbackResponse,
+ Rating,
+} from '@backstage/plugin-entity-feedback-common';
+
+/**
+ * @public
+ */
+export const entityFeedbackApiRef = createApiRef({
+ id: 'plugin.entity-feedback.service',
+});
+
+/**
+ * @public
+ */
+export interface EntityFeedbackApi {
+ getAllRatings(): Promise;
+
+ getOwnedRatings(ownerRef: string): Promise;
+
+ recordRating(entityRef: string, rating: string): Promise;
+
+ getRatings(entityRef: string): Promise[]>;
+
+ recordResponse(
+ entityRef: string,
+ response: Omit,
+ ): Promise;
+
+ getResponses(
+ entityRef: string,
+ ): Promise[]>;
+}
diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts
new file mode 100644
index 0000000000..694fd66b06
--- /dev/null
+++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+
+import { EntityFeedbackClient } from './EntityFeedbackClient';
+
+const server = setupServer();
+
+describe('EntityFeedbackClient', () => {
+ setupRequestMockHandlers(server);
+ const mockBaseUrl = 'http://backstage/api/entity-feedback';
+ const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
+ const fetchApi = new MockFetchApi();
+
+ let client: EntityFeedbackClient;
+ beforeEach(() => {
+ client = new EntityFeedbackClient({ discoveryApi, fetchApi });
+ });
+
+ it('getAllRatings', async () => {
+ const ratings = [
+ {
+ entityRef: 'component:default/foo',
+ entityTitle: 'Foo',
+ ratings: { LIKE: 10 },
+ },
+ {
+ entityRef: 'component:default/bar',
+ entityTitle: 'Bar',
+ ratings: { DISLIKE: 10 },
+ },
+ ];
+
+ server.use(
+ rest.get(`${mockBaseUrl}/ratings`, (_, res, ctx) =>
+ res(ctx.json(ratings)),
+ ),
+ );
+
+ const response = await client.getAllRatings();
+ expect(response).toEqual(ratings);
+ });
+
+ it('getOwnedRatings', async () => {
+ const ratings = [
+ {
+ entityRef: 'component:default/foo',
+ entityTitle: 'Foo',
+ ratings: { LIKE: 10 },
+ },
+ {
+ entityRef: 'component:default/bar',
+ entityTitle: 'Bar',
+ ratings: { DISLIKE: 10 },
+ },
+ ];
+
+ server.use(
+ rest.get(
+ `${mockBaseUrl}/ratings?ownerRef=${encodeURIComponent(
+ 'group:default/team',
+ )}`,
+ (_, res, ctx) => res(ctx.json(ratings)),
+ ),
+ );
+
+ const response = await client.getOwnedRatings('group:default/team');
+ expect(response).toEqual(ratings);
+ });
+
+ it('recordRating', async () => {
+ expect.assertions(1);
+
+ server.use(
+ rest.post(
+ `${mockBaseUrl}/ratings/${encodeURIComponent(
+ 'component:default/service',
+ )}`,
+ (req, res) => {
+ expect(req.body).toEqual({ rating: 'LIKE' });
+ return res();
+ },
+ ),
+ );
+
+ await client.recordRating('component:default/service', 'LIKE');
+ });
+
+ it('getRatings', async () => {
+ const ratings = [
+ { userRef: 'user:default/foo', rating: 'LIKE' },
+ { userRef: 'user:default/bar', rating: 'LIKE' },
+ ];
+
+ server.use(
+ rest.get(
+ `${mockBaseUrl}/ratings/${encodeURIComponent(
+ 'component:default/service',
+ )}`,
+ (_, res, ctx) => res(ctx.json(ratings)),
+ ),
+ );
+
+ const response = await client.getRatings('component:default/service');
+ expect(response).toEqual(ratings);
+ });
+
+ it('recordResponse', async () => {
+ expect.assertions(1);
+ const response = {
+ response: 'blah',
+ comments: 'feedback',
+ consent: false,
+ };
+
+ server.use(
+ rest.post(
+ `${mockBaseUrl}/responses/${encodeURIComponent(
+ 'component:default/service',
+ )}`,
+ (req, res) => {
+ expect(req.body).toEqual(response);
+ return res();
+ },
+ ),
+ );
+
+ await client.recordResponse('component:default/service', response);
+ });
+
+ it('getResponses', async () => {
+ const responses = [
+ {
+ userRef: 'user:default/foo',
+ response: 'asdf',
+ comments: 'here is new feedback',
+ consent: false,
+ },
+ {
+ userRef: 'user:default/bar',
+ response: 'noop',
+ comments: 'here is different feedback',
+ consent: true,
+ },
+ ];
+
+ server.use(
+ rest.get(
+ `${mockBaseUrl}/responses/${encodeURIComponent(
+ 'component:default/service',
+ )}`,
+ (_, res, ctx) => res(ctx.json(responses)),
+ ),
+ );
+
+ const response = await client.getResponses('component:default/service');
+ expect(response).toEqual(responses);
+ });
+});
diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts
new file mode 100644
index 0000000000..34856473c8
--- /dev/null
+++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
+import { ResponseError } from '@backstage/errors';
+import {
+ EntityRatingsData,
+ FeedbackResponse,
+ Rating,
+} from '@backstage/plugin-entity-feedback-common';
+
+import { EntityFeedbackApi } from './EntityFeedbackApi';
+
+/**
+ * @public
+ */
+export class EntityFeedbackClient implements EntityFeedbackApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly fetchApi: FetchApi;
+
+ constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
+ this.discoveryApi = options.discoveryApi;
+ this.fetchApi = options.fetchApi;
+ }
+
+ async getAllRatings(): Promise {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(`${baseUrl}/ratings`, {
+ method: 'GET',
+ });
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+
+ return resp.json();
+ }
+
+ async getOwnedRatings(ownerRef: string): Promise {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(
+ `${baseUrl}/ratings?ownerRef=${encodeURIComponent(ownerRef)}`,
+ {
+ method: 'GET',
+ },
+ );
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+
+ return resp.json();
+ }
+
+ async recordRating(entityRef: string, rating: string) {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(
+ `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`,
+ {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ body: JSON.stringify({ rating }),
+ },
+ );
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+ }
+
+ async getRatings(entityRef: string): Promise[]> {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(
+ `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`,
+ {
+ method: 'GET',
+ },
+ );
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+
+ return resp.json();
+ }
+
+ async recordResponse(
+ entityRef: string,
+ response: Omit,
+ ) {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(
+ `${baseUrl}/responses/${encodeURIComponent(entityRef)}`,
+ {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ body: JSON.stringify(response),
+ },
+ );
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+ }
+
+ async getResponses(
+ entityRef: string,
+ ): Promise[]> {
+ const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback');
+ const resp = await this.fetchApi.fetch(
+ `${baseUrl}/responses/${encodeURIComponent(entityRef)}`,
+ {
+ method: 'GET',
+ },
+ );
+
+ if (!resp.ok) {
+ throw await ResponseError.fromResponse(resp);
+ }
+
+ return resp.json();
+ }
+}
diff --git a/plugins/entity-feedback/src/api/index.ts b/plugins/entity-feedback/src/api/index.ts
new file mode 100644
index 0000000000..32c8624425
--- /dev/null
+++ b/plugins/entity-feedback/src/api/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './EntityFeedbackClient';
+export * from './EntityFeedbackApi';
diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx
new file mode 100644
index 0000000000..b639b2e727
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { entityRouteRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import React from 'react';
+import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
+import { FeedbackRatingsTable } from './FeedbackRatingsTable';
+
+describe('FeedbackRatingsTable', () => {
+ const sampleRatings = [
+ {
+ entityRef: 'component:default/foo',
+ entityTitle: 'Foo Component',
+ ratings: { 'Rating 1': 3, 'Rating 2': 1 },
+ },
+ {
+ entityRef: 'system:default/bar',
+ entityTitle: 'Bar Component',
+ ratings: { 'Rating 1': 5 },
+ },
+ {
+ entityRef: 'domain:default/hello-world',
+ entityTitle: 'Hello World',
+ ratings: { 'Rating 3': 5 },
+ },
+ ];
+
+ const feedbackApi: Partial = {
+ getAllRatings: jest.fn().mockImplementation(async () => sampleRatings),
+ getOwnedRatings: jest.fn().mockImplementation(async () => sampleRatings),
+ };
+
+ const sampleRatingValues = ['Rating 1', 'Rating 2'];
+
+ const render = async (props: any = {}) =>
+ renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
+ },
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders all ratings correctly', async () => {
+ const rendered = await render({ allEntities: true });
+
+ expect(feedbackApi.getAllRatings).toHaveBeenCalled();
+ expect(feedbackApi.getOwnedRatings).not.toHaveBeenCalled();
+
+ expect(rendered.getByText('Entity Ratings')).toBeInTheDocument();
+
+ // Columns
+ expect(rendered.getByText('Entity')).toBeInTheDocument();
+ expect(rendered.getByText('Rating 1')).toBeInTheDocument();
+ expect(rendered.getByText('Rating 2')).toBeInTheDocument();
+
+ // Rows
+ expect(rendered.getByText('Foo Component')).toBeInTheDocument();
+ expect(rendered.getByText('component')).toBeInTheDocument();
+ expect(rendered.getByText('3')).toBeInTheDocument();
+ expect(rendered.getByText('1')).toBeInTheDocument();
+ expect(rendered.getByText('Bar Component')).toBeInTheDocument();
+ expect(rendered.getByText('system')).toBeInTheDocument();
+ expect(rendered.getByText('5')).toBeInTheDocument();
+
+ expect(rendered.queryByText('Hello World')).toBeNull();
+ });
+
+ it('renders owned entity ratings correctly', async () => {
+ const rendered = await render({
+ ownerRef: 'group:default/test-team',
+ title: 'Custom Title',
+ });
+
+ expect(feedbackApi.getAllRatings).not.toHaveBeenCalled();
+ expect(feedbackApi.getOwnedRatings).toHaveBeenCalledWith(
+ 'group:default/test-team',
+ );
+
+ expect(rendered.getByText('Custom Title')).toBeInTheDocument();
+
+ // Columns
+ expect(rendered.getByText('Entity')).toBeInTheDocument();
+ expect(rendered.getByText('Rating 1')).toBeInTheDocument();
+ expect(rendered.getByText('Rating 2')).toBeInTheDocument();
+
+ // Rows
+ expect(rendered.getByText('Foo Component')).toBeInTheDocument();
+ expect(rendered.getByText('component')).toBeInTheDocument();
+ expect(rendered.getByText('3')).toBeInTheDocument();
+ expect(rendered.getByText('1')).toBeInTheDocument();
+ expect(rendered.getByText('Bar Component')).toBeInTheDocument();
+ expect(rendered.getByText('system')).toBeInTheDocument();
+ expect(rendered.getByText('5')).toBeInTheDocument();
+
+ expect(rendered.queryByText('Hello World')).toBeNull();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx
new file mode 100644
index 0000000000..48b2018729
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { parseEntityRef } from '@backstage/catalog-model';
+import { ErrorPanel, SubvalueCell, Table } from '@backstage/core-components';
+import { useApi } from '@backstage/core-plugin-api';
+import { EntityRefLink } from '@backstage/plugin-catalog-react';
+import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common';
+import React from 'react';
+import useAsync from 'react-use/lib/useAsync';
+
+import { entityFeedbackApiRef } from '../../api';
+
+interface FeedbackRatingsTableProps {
+ allEntities?: boolean;
+ ownerRef?: string;
+ ratingValues: string[];
+ title?: string;
+}
+
+export const FeedbackRatingsTable = (props: FeedbackRatingsTableProps) => {
+ const {
+ allEntities,
+ ownerRef,
+ ratingValues,
+ title = 'Entity Ratings',
+ } = props;
+ const feedbackApi = useApi(entityFeedbackApiRef);
+
+ const {
+ error,
+ loading,
+ value: ratings,
+ } = useAsync(async () => {
+ if (allEntities) {
+ return feedbackApi.getAllRatings();
+ }
+
+ if (!ownerRef) {
+ return [];
+ }
+
+ return feedbackApi.getOwnedRatings(ownerRef);
+ }, [allEntities, feedbackApi, ownerRef]);
+
+ const columns = [
+ { title: 'Title', field: 'entityTitle', hidden: true, searchable: true },
+ {
+ title: 'Entity',
+ field: 'entityRef',
+ highlight: true,
+ customSort: (a: EntityRatingsData, b: EntityRatingsData) => {
+ const titleA = a.entityTitle ?? parseEntityRef(a.entityRef).name;
+ const titleB = b.entityTitle ?? parseEntityRef(b.entityRef).name;
+ return titleA.localeCompare(titleB);
+ },
+ render: (rating: EntityRatingsData) => {
+ const compoundRef = parseEntityRef(rating.entityRef);
+ return (
+
+ }
+ subvalue={compoundRef.kind}
+ />
+ );
+ },
+ },
+ ...ratingValues.map(ratingVal => ({
+ title: ratingVal,
+ field: `ratings.${ratingVal}`,
+ })),
+ ];
+
+ // Exclude entities that don't have applicable ratings
+ const ratingsRows = ratings?.filter(r =>
+ Object.keys(r.ratings).some(v => ratingValues.includes(v)),
+ );
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ columns={columns}
+ data={ratingsRows ?? []}
+ isLoading={loading}
+ options={{
+ emptyRowsWhenPaging: false,
+ loadingType: 'linear',
+ pageSize: 20,
+ pageSizeOptions: [20, 50, 100],
+ paging: true,
+ showEmptyDataSourceMessage: !loading,
+ }}
+ title={title}
+ />
+ );
+};
diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts
new file mode 100644
index 0000000000..565063b3fa
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './FeedbackRatingsTable';
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx
new file mode 100644
index 0000000000..631738d6f9
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { getByRole, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
+import { FeedbackResponseDialog } from './FeedbackResponseDialog';
+
+describe('FeedbackResponseDialog', () => {
+ const testEntity: Partial = {
+ kind: 'component',
+ metadata: { name: 'test', namespace: 'default' },
+ };
+ const errorApi: Partial = { post: jest.fn() };
+ const feedbackApi: Partial = {
+ recordResponse: jest.fn().mockImplementation(() => Promise.resolve()),
+ };
+
+ const render = async (props: any = {}) =>
+ renderInTestApp(
+
+
+ ,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('allows customization of the dialog title', async () => {
+ const rendered = await render();
+ expect(
+ rendered.getByText('Please provide feedback on what can be improved'),
+ ).toBeInTheDocument();
+
+ const customRendered = await render({ feedbackDialogTitle: 'Test Title' });
+ expect(customRendered.getByText('Test Title')).toBeInTheDocument();
+ });
+
+ it('allows customization of the reponse options', async () => {
+ const rendered = await render();
+ expect(rendered.getByText('Incorrect info')).toBeInTheDocument();
+ expect(rendered.getByText('Missing info')).toBeInTheDocument();
+ expect(
+ rendered.getByText('Other (please specify below)'),
+ ).toBeInTheDocument();
+
+ const customResponses = [
+ { id: 'foo', label: 'Foo option' },
+ { id: 'bar', label: 'Bar option' },
+ ];
+ const customRendered = await render({
+ feedbackDialogResponses: customResponses,
+ });
+ expect(customRendered.getByText('Foo option')).toBeInTheDocument();
+ expect(customRendered.getByText('Bar option')).toBeInTheDocument();
+ });
+
+ it('handles saving user responses', async () => {
+ const rendered = await render();
+
+ await userEvent.click(
+ rendered.getByRole('checkbox', { name: 'Incorrect info' }),
+ );
+ await userEvent.click(
+ rendered.getByRole('checkbox', { name: 'Other (please specify below)' }),
+ );
+ await userEvent.type(
+ getByRole(
+ rendered.getByTestId('feedback-response-dialog-comments-input'),
+ 'textbox',
+ ),
+ 'test comments',
+ );
+ await userEvent.click(
+ rendered.getByTestId('feedback-response-dialog-submit-button'),
+ );
+
+ await waitFor(() => {
+ expect(feedbackApi.recordResponse).toHaveBeenCalledWith(
+ 'component:default/test',
+ {
+ comments: 'test comments',
+ consent: true,
+ response: 'incorrect,other',
+ },
+ );
+ });
+ });
+});
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx
new file mode 100644
index 0000000000..c3f935e432
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
+import { Progress } from '@backstage/core-components';
+import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api';
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ FormLabel,
+ Grid,
+ makeStyles,
+ Switch,
+ TextField,
+ Typography,
+} from '@material-ui/core';
+import React, { ReactNode, useState } from 'react';
+import useAsyncFn from 'react-use/lib/useAsyncFn';
+
+import { entityFeedbackApiRef } from '../../api';
+
+/**
+ * @public
+ */
+export interface EntityFeedbackResponse {
+ id: string;
+ label: string;
+}
+
+const defaultFeedbackResponses: EntityFeedbackResponse[] = [
+ { id: 'incorrect', label: 'Incorrect info' },
+ { id: 'missing', label: 'Missing info' },
+ { id: 'other', label: 'Other (please specify below)' },
+];
+
+/**
+ * @public
+ */
+export interface FeedbackResponseDialogProps {
+ entity: Entity;
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ feedbackDialogTitle?: ReactNode;
+ open: boolean;
+ onClose: () => void;
+}
+
+const useStyles = makeStyles({
+ contactConsent: {
+ marginTop: '5px',
+ },
+});
+
+export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => {
+ const {
+ entity,
+ feedbackDialogResponses = defaultFeedbackResponses,
+ feedbackDialogTitle = 'Please provide feedback on what can be improved',
+ open,
+ onClose,
+ } = props;
+ const classes = useStyles();
+ const errorApi = useApi(errorApiRef);
+ const feedbackApi = useApi(entityFeedbackApiRef);
+ const [responseSelections, setResponseSelections] = useState(
+ Object.fromEntries(feedbackDialogResponses.map(r => [r.id, false])),
+ );
+ const [comments, setComments] = useState('');
+ const [consent, setConsent] = useState(true);
+
+ const [{ loading: saving }, saveResponse] = useAsyncFn(async () => {
+ try {
+ await feedbackApi.recordResponse(stringifyEntityRef(entity), {
+ comments,
+ consent,
+ response: Object.keys(responseSelections)
+ .filter(id => responseSelections[id])
+ .join(','),
+ });
+ onClose();
+ } catch (e) {
+ errorApi.post(e as ErrorApiError);
+ }
+ }, [comments, consent, entity, feedbackApi, onClose, responseSelections]);
+
+ return (
+
+ );
+};
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts
new file mode 100644
index 0000000000..8ce88f4b87
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './FeedbackResponseDialog';
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx
new file mode 100644
index 0000000000..c5d3d51884
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import React from 'react';
+import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
+import { FeedbackResponseTable } from './FeedbackResponseTable';
+
+describe('FeedbackResponseTable', () => {
+ const sampleResponses = [
+ {
+ userRef: 'user:default/foo',
+ consent: true,
+ response: 'resp1,resp2',
+ comments: 'test comment 1',
+ },
+ {
+ userRef: 'user:default/bar',
+ consent: false,
+ response: 'resp3,resp4',
+ comments: 'test comment 2',
+ },
+ ];
+
+ const feedbackApi: Partial = {
+ getResponses: jest.fn().mockImplementation(async () => sampleResponses),
+ };
+
+ const render = async (props: any = {}) =>
+ renderInTestApp(
+
+
+ ,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders all responses correctly', async () => {
+ const rendered = await render();
+
+ expect(feedbackApi.getResponses).toHaveBeenCalledWith(
+ 'component:default/test',
+ );
+
+ expect(rendered.getByText('Entity Responses')).toBeInTheDocument();
+
+ expect(rendered.getByText('foo')).toBeInTheDocument();
+ expect(rendered.getByText('resp1')).toBeInTheDocument();
+ expect(rendered.getByText('resp2')).toBeInTheDocument();
+ expect(rendered.getByText('test comment 1')).toBeInTheDocument();
+ expect(rendered.getByText('bar')).toBeInTheDocument();
+ expect(rendered.getByText('resp3')).toBeInTheDocument();
+ expect(rendered.getByText('resp4')).toBeInTheDocument();
+ expect(rendered.getByText('test comment 2')).toBeInTheDocument();
+ });
+
+ it('renders a custom title correctly', async () => {
+ const rendered = await render({ title: 'Custom Title' });
+ expect(rendered.getByText('Custom Title')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx
new file mode 100644
index 0000000000..ba954c7747
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { parseEntityRef } from '@backstage/catalog-model';
+import { ErrorPanel, Table } from '@backstage/core-components';
+import { useApi } from '@backstage/core-plugin-api';
+import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
+import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common';
+import { BackstageTheme } from '@backstage/theme';
+import { Chip, makeStyles } from '@material-ui/core';
+import CheckIcon from '@material-ui/icons/Check';
+import React from 'react';
+import useAsync from 'react-use/lib/useAsync';
+
+import { entityFeedbackApiRef } from '../../api';
+
+type ResponseRow = Omit;
+
+const useStyles = makeStyles(theme => ({
+ consentCheck: {
+ color: theme.palette.status.ok,
+ },
+}));
+
+/**
+ * @public
+ */
+export interface FeedbackResponseTableProps {
+ entityRef: string;
+ title?: string;
+}
+
+export const FeedbackResponseTable = (props: FeedbackResponseTableProps) => {
+ const { entityRef, title = 'Entity Responses' } = props;
+ const classes = useStyles();
+ const feedbackApi = useApi(entityFeedbackApiRef);
+
+ const {
+ error,
+ loading,
+ value: responses,
+ } = useAsync(async () => {
+ if (!entityRef) {
+ return [];
+ }
+
+ return feedbackApi.getResponses(entityRef);
+ }, [entityRef, feedbackApi]);
+
+ const columns = [
+ {
+ title: 'User',
+ field: 'userRef',
+ width: '15%',
+ render: (response: ResponseRow) =>
+ humanizeEntityRef(parseEntityRef(response.userRef), {
+ defaultKind: 'user',
+ }),
+ },
+ {
+ title: 'OK to contact?',
+ field: 'consent',
+ width: '10%',
+ render: (response: ResponseRow) =>
+ response.consent ? : '',
+ },
+ {
+ title: 'Responses',
+ field: 'response',
+ width: '35%',
+ render: (response: ResponseRow) => (
+ <>
+ {response.response?.split(',').map(res => (
+
+ ))}
+ >
+ ),
+ },
+ { title: 'Comments', field: 'comments', width: '40%' },
+ ];
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ columns={columns}
+ data={(responses ?? []) as ResponseRow[]}
+ isLoading={loading}
+ options={{
+ emptyRowsWhenPaging: false,
+ loadingType: 'linear',
+ pageSize: 20,
+ pageSizeOptions: [20, 50, 100],
+ paging: true,
+ showEmptyDataSourceMessage: !loading,
+ }}
+ title={title}
+ />
+ );
+};
diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts
new file mode 100644
index 0000000000..73f4632fbf
--- /dev/null
+++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './FeedbackResponseTable';
diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx
new file mode 100644
index 0000000000..1edf578d3e
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+import {
+ ErrorApi,
+ errorApiRef,
+ IdentityApi,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
+import { AsyncEntityProvider } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
+import { FeedbackRatings, LikeDislikeButtons } from './LikeDislikeButtons';
+
+jest.mock('../FeedbackResponseDialog', () => ({
+ FeedbackResponseDialog: ({ open }: { open: boolean }) => {
+ return <>{open && dialog is open}>;
+ },
+}));
+
+describe('LikeDislikeButtons', () => {
+ const sampleRatings = [
+ {
+ userRef: 'user:default/me',
+ rating: FeedbackRatings.like,
+ },
+ {
+ userRef: 'user:default/someone',
+ rating: FeedbackRatings.dislike,
+ },
+ ];
+
+ const testEntity = {
+ kind: 'component',
+ metadata: { name: 'test', namespace: 'default' },
+ } as Entity;
+
+ const errorApi: Partial = { post: jest.fn() };
+
+ const feedbackApi: Partial = {
+ getRatings: jest.fn().mockImplementation(async () => sampleRatings),
+ recordRating: jest.fn().mockImplementation(() => Promise.resolve()),
+ };
+
+ const identityApi: Partial = {
+ getBackstageIdentity: async () => ({
+ type: 'user',
+ userEntityRef: 'user:default/me',
+ ownershipEntityRefs: [],
+ }),
+ };
+
+ const render = async (props: any = {}) =>
+ renderInTestApp(
+
+
+
+
+ ,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('loads the previous rating if it exists', async () => {
+ await render();
+ expect(feedbackApi.getRatings).toHaveBeenCalledWith(
+ 'component:default/test',
+ );
+ });
+
+ it('applies a rating correctly', async () => {
+ const rendered = await render();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-dislike-button'),
+ );
+ expect(feedbackApi.recordRating).toHaveBeenCalledWith(
+ 'component:default/test',
+ FeedbackRatings.dislike,
+ );
+
+ jest.clearAllMocks();
+
+ await userEvent.click(rendered.getByTestId('entity-feedback-like-button'));
+ expect(feedbackApi.recordRating).toHaveBeenCalledWith(
+ 'component:default/test',
+ FeedbackRatings.like,
+ );
+ });
+
+ it('removes an existing rating correctly', async () => {
+ const rendered = await render();
+
+ await userEvent.click(rendered.getByTestId('entity-feedback-like-button'));
+ expect(feedbackApi.recordRating).toHaveBeenCalledWith(
+ 'component:default/test',
+ FeedbackRatings.neutral,
+ );
+ });
+
+ it('opens a response dialog if dislike is selected', async () => {
+ const rendered = await render();
+
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-dislike-button'),
+ );
+ expect(rendered.getByText('dialog is open')).toBeInTheDocument();
+ });
+
+ it('does not open a response dialog on dislike if configured not to', async () => {
+ const rendered = await render({ requestResponse: false });
+
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-dislike-button'),
+ );
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx
new file mode 100644
index 0000000000..2d38e60ac2
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { Progress } from '@backstage/core-components';
+import {
+ ErrorApiError,
+ errorApiRef,
+ identityApiRef,
+ useApi,
+} from '@backstage/core-plugin-api';
+import { useAsyncEntity } from '@backstage/plugin-catalog-react';
+import { IconButton } from '@material-ui/core';
+import ThumbDownIcon from '@material-ui/icons/ThumbDown';
+import ThumbUpIcon from '@material-ui/icons/ThumbUp';
+import ThumbDownOutlinedIcon from '@material-ui/icons/ThumbDownOutlined';
+import ThumbUpOutlinedIcon from '@material-ui/icons/ThumbUpOutlined';
+import React, { ReactNode, useCallback, useState } from 'react';
+import useAsync from 'react-use/lib/useAsync';
+import useAsyncFn from 'react-use/lib/useAsyncFn';
+
+import { entityFeedbackApiRef } from '../../api';
+import {
+ EntityFeedbackResponse,
+ FeedbackResponseDialog,
+} from '../FeedbackResponseDialog';
+
+export enum FeedbackRatings {
+ like = 'LIKE',
+ dislike = 'DISLIKE',
+ neutral = 'NEUTRAL',
+}
+
+/**
+ * @public
+ */
+export interface LikeDislikeButtonsProps {
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ feedbackDialogTitle?: ReactNode;
+ requestResponse?: boolean;
+}
+
+export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => {
+ const {
+ feedbackDialogResponses,
+ feedbackDialogTitle,
+ requestResponse = true,
+ } = props;
+ const errorApi = useApi(errorApiRef);
+ const feedbackApi = useApi(entityFeedbackApiRef);
+ const identityApi = useApi(identityApiRef);
+ const [rating, setRating] = useState(
+ FeedbackRatings.neutral,
+ );
+ const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false);
+ const { entity, loading: loadingEntity } = useAsyncEntity();
+
+ const { loading: loadingFeedback } = useAsync(async () => {
+ // Wait until entity is loaded
+ if (!entity) {
+ return;
+ }
+
+ try {
+ const identity = await identityApi.getBackstageIdentity();
+ const prevFeedback = await feedbackApi.getRatings(
+ stringifyEntityRef(entity),
+ );
+ setRating(
+ (prevFeedback.find(r => r.userRef === identity.userEntityRef)?.rating ??
+ rating) as FeedbackRatings,
+ );
+ } catch (e) {
+ errorApi.post(e as ErrorApiError);
+ }
+ }, [entity, feedbackApi, setRating]);
+
+ const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn(
+ async (feedback: FeedbackRatings) => {
+ try {
+ await feedbackApi.recordRating(stringifyEntityRef(entity!), feedback);
+ setRating(feedback);
+ } catch (e) {
+ errorApi.post(e as ErrorApiError);
+ }
+ },
+ [entity, feedbackApi, setRating],
+ );
+
+ const applyRating = useCallback(
+ (feedback: FeedbackRatings) => {
+ // Clear rating if feedback is same as current
+ if (feedback === rating) {
+ saveFeedback(FeedbackRatings.neutral);
+ return;
+ }
+
+ saveFeedback(feedback);
+ if (feedback === FeedbackRatings.dislike && requestResponse) {
+ setOpenFeedbackDialog(true);
+ }
+ },
+ [rating, requestResponse, saveFeedback, setOpenFeedbackDialog],
+ );
+
+ if (loadingEntity || loadingFeedback || savingFeedback) {
+ return ;
+ }
+
+ return (
+ <>
+ applyRating(FeedbackRatings.like)}
+ >
+ {rating === FeedbackRatings.like ? (
+
+ ) : (
+
+ )}
+
+ applyRating(FeedbackRatings.dislike)}
+ >
+ {rating === FeedbackRatings.dislike ? (
+
+ ) : (
+
+ )}
+
+ setOpenFeedbackDialog(false)}
+ feedbackDialogResponses={feedbackDialogResponses}
+ feedbackDialogTitle={feedbackDialogTitle}
+ />
+ >
+ );
+};
diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts
new file mode 100644
index 0000000000..58038c32e1
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './LikeDislikeButtons';
diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx
new file mode 100644
index 0000000000..9f04d32ece
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp } from '@backstage/test-utils';
+import React from 'react';
+
+import { FeedbackRatings } from '../LikeDislikeButtons';
+import { LikeDislikeRatingsTable } from './LikeDislikeRatingsTable';
+
+jest.mock('../FeedbackRatingsTable', () => ({
+ FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => {
+ return (
+
+ {ratingValues.map(v => (
+ {v}
+ ))}
+
+ );
+ },
+}));
+
+describe('LikeDislikeRatingsTable', () => {
+ it('renders like-dislike ratings correctly', async () => {
+ const rendered = await renderInTestApp();
+ expect(rendered.getByText(FeedbackRatings.like)).toBeInTheDocument();
+ expect(rendered.queryByText(FeedbackRatings.neutral)).toBeNull();
+ expect(rendered.getByText(FeedbackRatings.dislike)).toBeInTheDocument();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx
new file mode 100644
index 0000000000..5466ba2b03
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { FeedbackRatingsTable } from '../FeedbackRatingsTable';
+import { FeedbackRatings } from '../LikeDislikeButtons';
+
+/**
+ * @public
+ */
+export interface LikeDislikeRatingsTableProps {
+ allEntities?: boolean;
+ ownerRef?: string;
+ title?: string;
+}
+
+export const LikeDislikeRatingsTable = (
+ props: LikeDislikeRatingsTableProps,
+) => {
+ const { allEntities, ownerRef, title } = props;
+
+ return (
+ r !== FeedbackRatings.neutral,
+ )}
+ title={title}
+ />
+ );
+};
diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts
new file mode 100644
index 0000000000..d093b8bd2c
--- /dev/null
+++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './LikeDislikeRatingsTable';
diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx
new file mode 100644
index 0000000000..82fc843494
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+import {
+ ErrorApi,
+ errorApiRef,
+ IdentityApi,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
+import { AsyncEntityProvider } from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api';
+import { FeedbackRatings, StarredRatingButtons } from './StarredRatingButtons';
+
+jest.mock('../FeedbackResponseDialog', () => ({
+ FeedbackResponseDialog: ({ open }: { open: boolean }) => {
+ return <>{open && dialog is open}>;
+ },
+}));
+
+describe('StarredRatingButtons', () => {
+ const sampleRatings = [
+ {
+ userRef: 'user:default/me',
+ rating: FeedbackRatings.two,
+ },
+ {
+ userRef: 'user:default/someone',
+ rating: FeedbackRatings.five,
+ },
+ ];
+
+ const testEntity = {
+ kind: 'component',
+ metadata: { name: 'test', namespace: 'default' },
+ } as Entity;
+
+ const errorApi: Partial = { post: jest.fn() };
+
+ const feedbackApi: Partial = {
+ getRatings: jest.fn().mockImplementation(async () => sampleRatings),
+ recordRating: jest.fn().mockImplementation(() => Promise.resolve()),
+ };
+
+ const identityApi: Partial = {
+ getBackstageIdentity: async () => ({
+ type: 'user',
+ userEntityRef: 'user:default/me',
+ ownershipEntityRefs: [],
+ }),
+ };
+
+ const render = async (props: any = {}) =>
+ renderInTestApp(
+
+
+
+
+ ,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('loads the previous rating if it exists', async () => {
+ await render();
+ expect(feedbackApi.getRatings).toHaveBeenCalledWith(
+ 'component:default/test',
+ );
+ });
+
+ it('applies a rating correctly', async () => {
+ const rendered = await render();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-3'),
+ );
+ expect(feedbackApi.recordRating).toHaveBeenCalledWith(
+ 'component:default/test',
+ FeedbackRatings.three.toString(),
+ );
+
+ jest.clearAllMocks();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-5'),
+ );
+ expect(feedbackApi.recordRating).toHaveBeenCalledWith(
+ 'component:default/test',
+ FeedbackRatings.five.toString(),
+ );
+ });
+
+ it('ignores an existing rating correctly', async () => {
+ const rendered = await render();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-2'),
+ );
+ expect(feedbackApi.recordRating).not.toHaveBeenCalled();
+ });
+
+ it('opens a response dialog if a rating under the threshold is selected', async () => {
+ const rendered = await render();
+
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-1'),
+ );
+ expect(rendered.getByText('dialog is open')).toBeInTheDocument();
+ });
+
+ it('opens a response dialog if a rating under a custom threshold is selected', async () => {
+ const rendered = await render({ requestResponseThreshold: 4 });
+
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-3'),
+ );
+ expect(rendered.getByText('dialog is open')).toBeInTheDocument();
+ });
+
+ it('does not open a response dialog if configured not to', async () => {
+ const rendered = await render({ requestResponse: false });
+
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+
+ await userEvent.click(
+ rendered.getByTestId('entity-feedback-star-button-1'),
+ );
+ expect(rendered.queryByText('dialog is open')).toBeNull();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx
new file mode 100644
index 0000000000..51f7da1099
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import { Progress } from '@backstage/core-components';
+import {
+ ErrorApiError,
+ errorApiRef,
+ identityApiRef,
+ useApi,
+} from '@backstage/core-plugin-api';
+import { useAsyncEntity } from '@backstage/plugin-catalog-react';
+import { IconButton } from '@material-ui/core';
+import StarOutlineIcon from '@material-ui/icons/StarOutline';
+import StarIcon from '@material-ui/icons/Star';
+import React, { ReactNode, useCallback, useState } from 'react';
+import useAsync from 'react-use/lib/useAsync';
+import useAsyncFn from 'react-use/lib/useAsyncFn';
+
+import { entityFeedbackApiRef } from '../../api';
+import {
+ EntityFeedbackResponse,
+ FeedbackResponseDialog,
+} from '../FeedbackResponseDialog';
+
+export enum FeedbackRatings {
+ one = 1,
+ two = 2,
+ three = 3,
+ four = 4,
+ five = 5,
+}
+
+/**
+ * @public
+ */
+export interface StarredRatingButtonsProps {
+ feedbackDialogResponses?: EntityFeedbackResponse[];
+ feedbackDialogTitle?: ReactNode;
+ requestResponse?: boolean;
+ requestResponseThreshold?: number;
+}
+
+export const StarredRatingButtons = (props: StarredRatingButtonsProps) => {
+ const {
+ feedbackDialogResponses,
+ feedbackDialogTitle,
+ requestResponse = true,
+ requestResponseThreshold = FeedbackRatings.two,
+ } = props;
+ const errorApi = useApi(errorApiRef);
+ const feedbackApi = useApi(entityFeedbackApiRef);
+ const identityApi = useApi(identityApiRef);
+ const [rating, setRating] = useState();
+ const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false);
+ const { entity, loading: loadingEntity } = useAsyncEntity();
+
+ const { loading: loadingFeedback } = useAsync(async () => {
+ // Wait until entity is loaded
+ if (!entity) {
+ return;
+ }
+
+ try {
+ const identity = await identityApi.getBackstageIdentity();
+ const prevFeedback = await feedbackApi.getRatings(
+ stringifyEntityRef(entity),
+ );
+
+ const prevRating = prevFeedback.find(
+ r => r.userRef === identity.userEntityRef,
+ )?.rating;
+ if (prevRating) {
+ setRating(parseInt(prevRating, 10));
+ }
+ } catch (e) {
+ errorApi.post(e as ErrorApiError);
+ }
+ }, [entity, feedbackApi, setRating]);
+
+ const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn(
+ async (feedback: FeedbackRatings) => {
+ try {
+ await feedbackApi.recordRating(
+ stringifyEntityRef(entity!),
+ feedback.toString(),
+ );
+ setRating(feedback);
+ } catch (e) {
+ errorApi.post(e as ErrorApiError);
+ }
+ },
+ [entity, feedbackApi, setRating],
+ );
+
+ const applyRating = useCallback(
+ (feedback: FeedbackRatings) => {
+ // Ignore rating if feedback is same as current
+ if (feedback === rating) {
+ return;
+ }
+
+ saveFeedback(feedback);
+ if (feedback <= requestResponseThreshold && requestResponse) {
+ setOpenFeedbackDialog(true);
+ }
+ },
+ [
+ rating,
+ requestResponse,
+ requestResponseThreshold,
+ saveFeedback,
+ setOpenFeedbackDialog,
+ ],
+ );
+
+ if (loadingEntity || loadingFeedback || savingFeedback) {
+ return ;
+ }
+
+ return (
+ <>
+ {Object.values(FeedbackRatings)
+ .filter(o => typeof o === 'number')
+ .map(starRating => (
+ applyRating(starRating as FeedbackRatings)}
+ >
+ {rating && rating >= starRating ? (
+
+ ) : (
+
+ )}
+
+ ))}
+ setOpenFeedbackDialog(false)}
+ feedbackDialogResponses={feedbackDialogResponses}
+ feedbackDialogTitle={feedbackDialogTitle}
+ />
+ >
+ );
+};
diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts
new file mode 100644
index 0000000000..659c914749
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './StarredRatingButtons';
diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx
new file mode 100644
index 0000000000..be53d59d5f
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp } from '@backstage/test-utils';
+import React from 'react';
+
+import { FeedbackRatings } from '../StarredRatingButtons';
+import { StarredRatingsTable } from './StarredRatingsTable';
+
+jest.mock('../FeedbackRatingsTable', () => ({
+ FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => {
+ return (
+
+ {ratingValues.map(v => (
+ {v}
+ ))}
+
+ );
+ },
+}));
+
+describe('StarredRatingsTable', () => {
+ it('renders starred ratings correctly', async () => {
+ const rendered = await renderInTestApp();
+ expect(rendered.getByText(FeedbackRatings.one)).toBeInTheDocument();
+ expect(rendered.getByText(FeedbackRatings.two)).toBeInTheDocument();
+ expect(rendered.getByText(FeedbackRatings.three)).toBeInTheDocument();
+ expect(rendered.getByText(FeedbackRatings.four)).toBeInTheDocument();
+ expect(rendered.getByText(FeedbackRatings.five)).toBeInTheDocument();
+ });
+});
diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx
new file mode 100644
index 0000000000..d52fd64640
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { FeedbackRatingsTable } from '../FeedbackRatingsTable';
+import { FeedbackRatings } from '../StarredRatingButtons';
+
+/**
+ * @public
+ */
+export interface StarredRatingsTableProps {
+ allEntities?: boolean;
+ ownerRef?: string;
+ title?: string;
+}
+
+export const StarredRatingsTable = (props: StarredRatingsTableProps) => {
+ const { allEntities, ownerRef, title } = props;
+
+ return (
+ typeof o === 'number')
+ .map(r => r.toString())}
+ title={title}
+ />
+ );
+};
diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts
new file mode 100644
index 0000000000..9621712e0b
--- /dev/null
+++ b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './StarredRatingsTable';
diff --git a/plugins/entity-feedback/src/components/index.ts b/plugins/entity-feedback/src/components/index.ts
new file mode 100644
index 0000000000..a88d525f09
--- /dev/null
+++ b/plugins/entity-feedback/src/components/index.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type {
+ EntityFeedbackResponse,
+ FeedbackResponseDialogProps,
+} from './FeedbackResponseDialog';
+export type { FeedbackResponseTableProps } from './FeedbackResponseTable';
+export type { LikeDislikeButtonsProps } from './LikeDislikeButtons';
+export type { LikeDislikeRatingsTableProps } from './LikeDislikeRatingsTable';
+export type { StarredRatingButtonsProps } from './StarredRatingButtons';
+export type { StarredRatingsTableProps } from './StarredRatingsTable';
diff --git a/plugins/entity-feedback/src/index.ts b/plugins/entity-feedback/src/index.ts
new file mode 100644
index 0000000000..bbcf6bc626
--- /dev/null
+++ b/plugins/entity-feedback/src/index.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Entity Feedback frontend plugin
+ *
+ * @packageDocumentation
+ */
+export type {
+ LikeDislikeButtonsProps,
+ LikeDislikeRatingsTableProps,
+ EntityFeedbackResponse,
+ FeedbackResponseDialogProps,
+ FeedbackResponseTableProps,
+ StarredRatingButtonsProps,
+ StarredRatingsTableProps,
+} from './components';
+export * from './plugin';
+export * from './api';
diff --git a/plugins/entity-feedback/src/plugin.test.ts b/plugins/entity-feedback/src/plugin.test.ts
new file mode 100644
index 0000000000..a56d37f9ab
--- /dev/null
+++ b/plugins/entity-feedback/src/plugin.test.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { entityFeedbackPlugin } from './plugin';
+
+describe('entity-feedback', () => {
+ it('should export plugin', () => {
+ expect(entityFeedbackPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/entity-feedback/src/plugin.tsx b/plugins/entity-feedback/src/plugin.tsx
new file mode 100644
index 0000000000..9ebdc5d7af
--- /dev/null
+++ b/plugins/entity-feedback/src/plugin.tsx
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { stringifyEntityRef } from '@backstage/catalog-model';
+import {
+ createApiFactory,
+ createComponentExtension,
+ createPlugin,
+ createRoutableExtension,
+ discoveryApiRef,
+ fetchApiRef,
+} from '@backstage/core-plugin-api';
+import { useAsyncEntity } from '@backstage/plugin-catalog-react';
+import React from 'react';
+
+import { entityFeedbackApiRef, EntityFeedbackClient } from './api';
+import { rootRouteRef } from './routes';
+
+/**
+ * @public
+ */
+export const entityFeedbackPlugin = createPlugin({
+ id: 'entity-feedback',
+ routes: {
+ root: rootRouteRef,
+ },
+ apis: [
+ createApiFactory({
+ api: entityFeedbackApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ fetchApi: fetchApiRef,
+ },
+ factory: ({ discoveryApi, fetchApi }) =>
+ new EntityFeedbackClient({ discoveryApi, fetchApi }),
+ }),
+ ],
+});
+
+/**
+ * @public
+ */
+export const LikeDislikeButtons = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'LikeDislikeButtons',
+ component: {
+ lazy: () =>
+ import('./components/LikeDislikeButtons').then(
+ m => m.LikeDislikeButtons,
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const StarredRatingButtons = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'StarredRatingButtons',
+ component: {
+ lazy: () =>
+ import('./components/StarredRatingButtons').then(
+ m => m.StarredRatingButtons,
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const FeedbackResponseDialog = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'FeedbackResponseDialog',
+ component: {
+ lazy: () =>
+ import('./components/FeedbackResponseDialog').then(
+ m => m.FeedbackResponseDialog,
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const EntityFeedbackResponseContent = entityFeedbackPlugin.provide(
+ createRoutableExtension({
+ name: 'EntityFeedbackResponseContent',
+ mountPoint: rootRouteRef,
+ component: () =>
+ import('./components/FeedbackResponseTable').then(
+ ({ FeedbackResponseTable }) => {
+ return () => {
+ const { entity } = useAsyncEntity();
+ return (
+
+ );
+ };
+ },
+ ),
+ }),
+);
+
+/**
+ * @public
+ */
+export const FeedbackResponseTable = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'FeedbackResponseTable',
+ component: {
+ lazy: () =>
+ import('./components/FeedbackResponseTable').then(
+ m => m.FeedbackResponseTable,
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const EntityLikeDislikeRatingsCard = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'EntityLikeDislikeRatingsCard',
+ component: {
+ lazy: () =>
+ import('./components/LikeDislikeRatingsTable').then(
+ ({ LikeDislikeRatingsTable }) => {
+ return () => {
+ const { entity } = useAsyncEntity();
+ return (
+
+ );
+ };
+ },
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const LikeDislikeRatingsTable = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'LikeDislikeRatingsTable',
+ component: {
+ lazy: () =>
+ import('./components/LikeDislikeRatingsTable').then(
+ m => m.LikeDislikeRatingsTable,
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const EntityStarredRatingsCard = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'EntityStarredRatingsCard',
+ component: {
+ lazy: () =>
+ import('./components/StarredRatingsTable').then(
+ ({ StarredRatingsTable }) => {
+ return () => {
+ const { entity } = useAsyncEntity();
+ return (
+
+ );
+ };
+ },
+ ),
+ },
+ }),
+);
+
+/**
+ * @public
+ */
+export const StarredRatingsTable = entityFeedbackPlugin.provide(
+ createComponentExtension({
+ name: 'StarredRatingsTable',
+ component: {
+ lazy: () =>
+ import('./components/StarredRatingsTable').then(
+ m => m.StarredRatingsTable,
+ ),
+ },
+ }),
+);
diff --git a/plugins/entity-feedback/src/routes.ts b/plugins/entity-feedback/src/routes.ts
new file mode 100644
index 0000000000..cde7241931
--- /dev/null
+++ b/plugins/entity-feedback/src/routes.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createRouteRef } from '@backstage/core-plugin-api';
+
+export const rootRouteRef = createRouteRef({
+ id: 'entity-feedback',
+});
diff --git a/plugins/entity-feedback/src/setupTests.ts b/plugins/entity-feedback/src/setupTests.ts
new file mode 100644
index 0000000000..bf4855cb6e
--- /dev/null
+++ b/plugins/entity-feedback/src/setupTests.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '@testing-library/jest-dom';
+import 'cross-fetch/polyfill';
diff --git a/yarn.lock b/yarn.lock
index 345c28a8b5..c45112197f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5844,6 +5844,72 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/plugin-entity-feedback-backend@workspace:^, @backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend"
+ dependencies:
+ "@backstage/backend-common": "workspace:^"
+ "@backstage/backend-test-utils": "workspace:^"
+ "@backstage/catalog-client": "workspace:^"
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/plugin-auth-node": "workspace:^"
+ "@backstage/plugin-entity-feedback-common": "workspace:^"
+ "@types/express": "*"
+ "@types/supertest": ^2.0.12
+ express: ^4.18.1
+ express-promise-router: ^4.1.0
+ knex: ^2.0.0
+ msw: ^0.49.0
+ node-fetch: ^2.6.7
+ supertest: ^6.2.4
+ winston: ^3.2.1
+ yn: ^4.0.0
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-entity-feedback-common@workspace:^, @backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-entity-feedback@workspace:^, @backstage/plugin-entity-feedback@workspace:plugins/entity-feedback":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-entity-feedback@workspace:plugins/entity-feedback"
+ dependencies:
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-components": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/dev-utils": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/plugin-catalog-react": "workspace:^"
+ "@backstage/plugin-entity-feedback-common": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@backstage/theme": "workspace:^"
+ "@material-ui/core": ^4.9.13
+ "@material-ui/icons": ^4.9.1
+ "@material-ui/lab": 4.0.0-alpha.57
+ "@testing-library/jest-dom": ^5.10.1
+ "@testing-library/react": ^12.1.3
+ "@testing-library/react-hooks": ^8.0.1
+ "@testing-library/user-event": ^14.0.0
+ "@types/node": "*"
+ cross-fetch: ^3.1.5
+ msw: ^0.49.0
+ react-use: ^17.2.4
+ peerDependencies:
+ "@types/react": ^16.13.1 || ^17.0.0
+ react: ^16.13.1 || ^17.0.0
+ react-router-dom: 6.0.0-beta.0 || ^6.3.0
+ languageName: unknown
+ linkType: soft
+
"@backstage/plugin-entity-validation@workspace:plugins/entity-validation":
version: 0.0.0-use.local
resolution: "@backstage/plugin-entity-validation@workspace:plugins/entity-validation"
@@ -11244,7 +11310,7 @@ __metadata:
languageName: node
linkType: hard
-"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4":
+"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13":
version: 4.12.4
resolution: "@material-ui/core@npm:4.12.4"
dependencies:
@@ -13628,7 +13694,7 @@ __metadata:
languageName: node
linkType: hard
-"@testing-library/react-hooks@npm:^8.0.0":
+"@testing-library/react-hooks@npm:^8.0.0, @testing-library/react-hooks@npm:^8.0.1":
version: 8.0.1
resolution: "@testing-library/react-hooks@npm:8.0.1"
dependencies:
@@ -22216,6 +22282,7 @@ __metadata:
"@backstage/plugin-code-coverage": "workspace:^"
"@backstage/plugin-cost-insights": "workspace:^"
"@backstage/plugin-dynatrace": "workspace:^"
+ "@backstage/plugin-entity-feedback": "workspace:^"
"@backstage/plugin-explore": "workspace:^"
"@backstage/plugin-gcalendar": "workspace:^"
"@backstage/plugin-gcp-projects": "workspace:^"
@@ -22318,6 +22385,7 @@ __metadata:
"@backstage/plugin-catalog-backend": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-code-coverage-backend": "workspace:^"
+ "@backstage/plugin-entity-feedback-backend": "workspace:^"
"@backstage/plugin-events-backend": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-explore-backend": "workspace:^"