From a3c86a7ed2510df014dcab21f2bb36236370f0ca Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Thu, 19 Jan 2023 15:00:48 -0500 Subject: [PATCH] feat(entity-feedback): implement plugin Signed-off-by: Phil Kuang --- .changeset/gorgeous-llamas-mate.md | 7 + microsite/data/plugins/entity-feedback.yaml | 10 + microsite/static/img/entity-feedback-logo.png | Bin 0 -> 894 bytes packages/app/package.json | 1 + .../app/src/components/catalog/EntityPage.tsx | 56 +++- packages/backend/package.json | 1 + packages/backend/src/index.ts | 5 + .../backend/src/plugins/entityFeedback.ts | 28 ++ plugins/entity-feedback-backend/.eslintrc.js | 1 + plugins/entity-feedback-backend/README.md | 50 +++ plugins/entity-feedback-backend/api-report.md | 26 ++ .../migrations/20230109124329_init.js | 69 ++++ plugins/entity-feedback-backend/package.json | 50 +++ plugins/entity-feedback-backend/src/index.ts | 22 ++ plugins/entity-feedback-backend/src/run.ts | 33 ++ .../src/service/DatabaseHandler.test.ts | 310 ++++++++++++++++++ .../src/service/DatabaseHandler.ts | 161 +++++++++ .../src/service/router.test.ts | 290 ++++++++++++++++ .../src/service/router.ts | 215 ++++++++++++ .../src/service/standaloneServer.ts | 80 +++++ .../entity-feedback-backend/src/setupTests.ts | 17 + plugins/entity-feedback-common/.eslintrc.js | 1 + plugins/entity-feedback-common/README.md | 3 + plugins/entity-feedback-common/api-report.md | 42 +++ plugins/entity-feedback-common/package.json | 31 ++ plugins/entity-feedback-common/src/index.ts | 52 +++ .../entity-feedback-common/src/setupTests.ts | 16 + plugins/entity-feedback/.eslintrc.js | 1 + plugins/entity-feedback/README.md | 136 ++++++++ plugins/entity-feedback/api-report.md | 180 ++++++++++ .../docs/feedback-response-dialog.png | Bin 0 -> 46890 bytes .../docs/feedback-response-table.png | Bin 0 -> 32787 bytes .../docs/like-dislike-rating.png | Bin 0 -> 8754 bytes .../docs/like-dislike-table.png | Bin 0 -> 39177 bytes .../docs/starred-rating-table.png | Bin 0 -> 32379 bytes .../entity-feedback/docs/starred-rating.png | Bin 0 -> 9677 bytes plugins/entity-feedback/package.json | 63 ++++ .../src/api/EntityFeedbackApi.ts | 49 +++ .../src/api/EntityFeedbackClient.test.ts | 175 ++++++++++ .../src/api/EntityFeedbackClient.ts | 136 ++++++++ plugins/entity-feedback/src/api/index.ts | 18 + .../FeedbackRatingsTable.test.tsx | 117 +++++++ .../FeedbackRatingsTable.tsx | 123 +++++++ .../components/FeedbackRatingsTable/index.ts | 17 + .../FeedbackResponseDialog.test.tsx | 118 +++++++ .../FeedbackResponseDialog.tsx | 176 ++++++++++ .../FeedbackResponseDialog/index.ts | 17 + .../FeedbackResponseTable.test.tsx | 76 +++++ .../FeedbackResponseTable.tsx | 121 +++++++ .../components/FeedbackResponseTable/index.ts | 17 + .../LikeDislikeButtons.test.tsx | 147 +++++++++ .../LikeDislikeButtons/LikeDislikeButtons.tsx | 154 +++++++++ .../components/LikeDislikeButtons/index.ts | 17 + .../LikeDislikeRatingsTable.test.tsx | 42 +++ .../LikeDislikeRatingsTable.tsx | 46 +++ .../LikeDislikeRatingsTable/index.ts | 17 + .../StarredRatingButtons.test.tsx | 159 +++++++++ .../StarredRatingButtons.tsx | 160 +++++++++ .../components/StarredRatingButtons/index.ts | 17 + .../StarredRatingsTable.test.tsx | 44 +++ .../StarredRatingsTable.tsx | 44 +++ .../components/StarredRatingsTable/index.ts | 17 + .../entity-feedback/src/components/index.ts | 25 ++ plugins/entity-feedback/src/index.ts | 32 ++ plugins/entity-feedback/src/plugin.test.ts | 23 ++ plugins/entity-feedback/src/plugin.tsx | 212 ++++++++++++ plugins/entity-feedback/src/routes.ts | 21 ++ plugins/entity-feedback/src/setupTests.ts | 18 + yarn.lock | 72 +++- 69 files changed, 4381 insertions(+), 3 deletions(-) create mode 100644 .changeset/gorgeous-llamas-mate.md create mode 100644 microsite/data/plugins/entity-feedback.yaml create mode 100644 microsite/static/img/entity-feedback-logo.png create mode 100644 packages/backend/src/plugins/entityFeedback.ts create mode 100644 plugins/entity-feedback-backend/.eslintrc.js create mode 100644 plugins/entity-feedback-backend/README.md create mode 100644 plugins/entity-feedback-backend/api-report.md create mode 100644 plugins/entity-feedback-backend/migrations/20230109124329_init.js create mode 100644 plugins/entity-feedback-backend/package.json create mode 100644 plugins/entity-feedback-backend/src/index.ts create mode 100644 plugins/entity-feedback-backend/src/run.ts create mode 100644 plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts create mode 100644 plugins/entity-feedback-backend/src/service/DatabaseHandler.ts create mode 100644 plugins/entity-feedback-backend/src/service/router.test.ts create mode 100644 plugins/entity-feedback-backend/src/service/router.ts create mode 100644 plugins/entity-feedback-backend/src/service/standaloneServer.ts create mode 100644 plugins/entity-feedback-backend/src/setupTests.ts create mode 100644 plugins/entity-feedback-common/.eslintrc.js create mode 100644 plugins/entity-feedback-common/README.md create mode 100644 plugins/entity-feedback-common/api-report.md create mode 100644 plugins/entity-feedback-common/package.json create mode 100644 plugins/entity-feedback-common/src/index.ts create mode 100644 plugins/entity-feedback-common/src/setupTests.ts create mode 100644 plugins/entity-feedback/.eslintrc.js create mode 100644 plugins/entity-feedback/README.md create mode 100644 plugins/entity-feedback/api-report.md create mode 100644 plugins/entity-feedback/docs/feedback-response-dialog.png create mode 100644 plugins/entity-feedback/docs/feedback-response-table.png create mode 100644 plugins/entity-feedback/docs/like-dislike-rating.png create mode 100644 plugins/entity-feedback/docs/like-dislike-table.png create mode 100644 plugins/entity-feedback/docs/starred-rating-table.png create mode 100644 plugins/entity-feedback/docs/starred-rating.png create mode 100644 plugins/entity-feedback/package.json create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackApi.ts create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackClient.ts create mode 100644 plugins/entity-feedback/src/api/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/index.ts create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/index.ts create mode 100644 plugins/entity-feedback/src/index.ts create mode 100644 plugins/entity-feedback/src/plugin.test.ts create mode 100644 plugins/entity-feedback/src/plugin.tsx create mode 100644 plugins/entity-feedback/src/routes.ts create mode 100644 plugins/entity-feedback/src/setupTests.ts 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/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 0000000000000000000000000000000000000000..e172b260a0027bb669bb105d8e1e095061ae3691 GIT binary patch literal 894 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G|oWRD45dJguM!v-tY$DUh!@P+6=(yLU`z6LcLCBs@Y8vBJ&@uo z@Q5r1(g|SvA=~LZ0|Qfor;B4q#=WaEyt73dMciKdB^W~l)A?;2c4uuf`B3_RF>mjh zS?Ag15;kVJ$yYR-4lVw5fY~SP?5n1HhShvGVoU!qORSt_uKS10VEwwap=VzkZMeW( zC$eMWBJZ!4#0vKDFw4h$*!H%wow1?Z^)q+FuCf@HsBP=(jgQHE)>2S%m0DrRwe8Kr zCHgg*m)_qozP`h2d%Q{gS$o&%&#qK{Jl(hF9z($m!~8QXDG$25zOq_KO=>Kix+>D5 zCXTJaSnI6(0~ZgrX(6+o+I?K!kbc1N7-x}m22*K1^8twqVVTQ*ANhZVW8KyX?Ty)5 zN7WQKKbjXevETS;^xd(rNVqY^zNC}S1$2Bkh9WnO-Ts5`sYZ4bNZylJ@pwe zm#izd?`=P4#QJ7+yV<*$>%CjwNtAFsn~>(^VixoB)C;G5zfyZDlOMX5CQGv2tG@T& z$>m?vkK?Oyck?iBJ~l(Q|M&dk55AuJaaZUEyYPl(`z}{5dh53~bU~o$`ZjsRTZj6y z%{eNv6T1FYJuwy(%S}*=sK|a0w&BCI1FPje@VCp|lghAu5Z?Bw`EBBZT#)e28;|sL ze$43PKJa(ThyM)j;UAo*Gfv+NOq8l6t`Q|Ei6yC4$wjF^iowXh$XM6FP}k5h#K_1B rh)m724GgRd4E!9{8lz~)%}>cptHiCrURJapsDZ)L)z4*}Q$iB}C{%i? literal 0 HcmV?d00001 diff --git a/packages/app/package.json b/packages/app/package.json index 1b3841ceae..d6a05d58fc 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 3566800f0b..4b7722008e 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, @@ -374,6 +379,12 @@ const overviewContent = ( + + + + + + {cicdCard} @@ -511,6 +522,10 @@ const serviceEntityPage = ( > + + + + ); @@ -590,6 +605,10 @@ const websiteEntityPage = ( + + + + ); @@ -606,6 +625,10 @@ const defaultEntityPage = ( + + + + ); @@ -642,6 +665,11 @@ const apiPage = ( + + + + + @@ -654,6 +682,10 @@ const apiPage = ( + + + + ); @@ -671,6 +703,9 @@ const userPage = ( entityFilterKind={customEntityFilterKind} /> + + + @@ -693,6 +728,9 @@ const groupPage = ( + + + @@ -718,6 +756,11 @@ const systemPage = ( + + + + + @@ -746,6 +789,9 @@ const systemPage = ( unidirectional={false} /> + + + ); @@ -763,8 +809,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..487d5f5d5e --- /dev/null +++ b/plugins/entity-feedback-backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-entity-feedback-backend", + "version": "0.1.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..aeeaffd694 --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts @@ -0,0 +1,161 @@ +/* + * 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 { Rating, Response } 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: Response) { + 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..35da32c1da --- /dev/null +++ b/plugins/entity-feedback-common/api-report.md @@ -0,0 +1,42 @@ +## 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 Rating { + // (undocumented) + entityRef: string; + // (undocumented) + rating: string; + // (undocumented) + userRef: string; +} + +// @public (undocumented) +interface Response_2 { + // (undocumented) + comments?: string; + // (undocumented) + consent?: boolean; + // (undocumented) + entityRef: string; + // (undocumented) + response?: string; + // (undocumented) + userRef: string; +} +export { Response_2 as Response }; +``` diff --git a/plugins/entity-feedback-common/package.json b/plugins/entity-feedback-common/package.json new file mode 100644 index 0000000000..45a5a2b131 --- /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.1.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..f19fcdd648 --- /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 Response { + entityRef: string; + response?: string; + comments?: string; + consent?: boolean; + userRef: string; +} + +/** + * @public + */ +export interface EntityRatingsData { + entityRef: string; + entityTitle?: string; + ratings: { + [ratingValue: string]: number; + }; +} diff --git a/plugins/entity-feedback-common/src/setupTests.ts b/plugins/entity-feedback-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/entity-feedback-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/entity-feedback/.eslintrc.js b/plugins/entity-feedback/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback/README.md b/plugins/entity-feedback/README.md new file mode 100644 index 0000000000..fdfa4761d6 --- /dev/null +++ b/plugins/entity-feedback/README.md @@ -0,0 +1,136 @@ +# Entity Feedback Plugin + +Welcome to the entity-feedback plugin! + +This plugin allows you give and view feedback on entities available in the Backstage catalog. + +## Features + +### Rate entities + +#### Like/Dislike rating + +![Like dislike rating example](./docs/like-dislike-rating.png) + +#### Starred rating + +![Starred rating example](./docs/starred-rating.png) + +### Request additional feedback when poorly rated + +![Response dialog example](./docs/feedback-response-dialog.png) + +### View entity feedback responses + +![Feedback responses example](./docs/feedback-response-table.png) + +### View aggregated ratings on owned entities + +#### Total likes/dislikes + +![Like dislike table example](./docs/like-dislike-table.png) + +#### Star breakdowns + +![Starred rating table example](./docs/starred-rating-table.png) + +## Setup + +The following sections will help you get the Entity Feedback plugin setup and running + +### Backend + +You need to setup the [Entity Feedback backend plugin](https://github.com/backstage/backstage/tree/master/plugins/entity-feedback-backend) before you move forward with any of these steps if you haven't already + +### Installation + +Install this plugin: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-entity-feedback +``` + +### Entity Pages + +Add rating and feedback components to your `EntityPage.tsx` to hook up UI so that users +can rate your entities and for owners to view feedback/responses. + +To allow users to apply "Like" and "Dislike" ratings add the following to each kind/type of +entity in your `EntityPage.tsx` you want to be rated (if you prefer to use star ratings, replace +`EntityLikeDislikeRatingsCard` with `EntityStarredRatingsCard` and `LikeDislikeButtons` with +`StarredRatingButtons`): + +```diff +import { + ... ++ InfoCard, + ... +} from '@backstage/core-components'; ++import { ++ EntityFeedbackResponseContent, ++ EntityLikeDislikeRatingsCard, ++ LikeDislikeButtons, ++} from '@backstage/plugin-entity-feedback'; + +// Add to each applicable kind/type of entity as desired +const overviewContent = ( + + ... ++ ++ ++ ++ ++ + ... + +); + +... + +// Add to each applicable kind/type of entity as desired +const serviceEntityPage = ( + + ... ++ ++ ++ + ... + +); + +... + +// Add ratings card component to user/group entities to view ratings of owned entities +const userPage = ( + + + + ... ++ ++ ++ + ... + + + + +); + +const groupPage = ( + + + + ... ++ ++ ++ + ... + + + + +); +``` + +Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx) diff --git a/plugins/entity-feedback/api-report.md b/plugins/entity-feedback/api-report.md new file mode 100644 index 0000000000..399e84e674 --- /dev/null +++ b/plugins/entity-feedback/api-report.md @@ -0,0 +1,180 @@ +## 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 { FetchApi } from '@backstage/core-plugin-api'; +import { Rating } from '@backstage/plugin-entity-feedback-common'; +import { ReactNode } from 'react'; +import { Response as Response_2 } from '@backstage/plugin-entity-feedback-common'; +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 0000000000000000000000000000000000000000..74270c805a549412e34b0e95785114f601ca60ba GIT binary patch literal 46890 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3795ac809d07381fd734818f51dc3afa915755f8 GIT binary patch literal 32787 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 Rptq08N-Euaena2y{{ed7=1>3t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0267218243ab67ccad8417f9c6bcb15e84ede7dd GIT binary patch literal 8754 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df38883d2546c38295c3722beb771f1a7f0cca15 GIT binary patch literal 39177 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 literal 0 HcmV?d00001 diff --git a/plugins/entity-feedback/docs/starred-rating.png b/plugins/entity-feedback/docs/starred-rating.png new file mode 100644 index 0000000000000000000000000000000000000000..6198f7ae664d1278bfd6dd5de040c37c0fe5d519 GIT binary patch literal 9677 zcmd6MWmsIn(&j*b;2JCtEJ$#75AL2ofB}L98Qk512MF#CA-GFmaCZqF26uOdJ-PDT z`|SSOU%T@>XHHFbRdsc>y>CyLsT6UG2!tUgE2$0w!3hI$Dl!uAy?B}B3j(38 zSV%~y%1KC2s5;o1Sy-EbK(b*8NiQ`u244GZ!!Ba5W#1#@2f+|_5n^yXKm{MwP~<3b zf|9Wj4hGjy%TcMQv_Di*qL!EYl_FLdN_7Nyixa?^>NCx$&Z7x+9<`ps1dm0{kG!_q zo*zs{hlQUJ{YZxsWG9XAK^)yWlXkwWX4ZN_c(Yg(VlOTIP?;KqQi9&RevOWulKRYY zd`vkL`lp`s`ttbrxkU9Ao)`=C4sMmYH8RQH4vhnRNsM0O0NE{bLMlaY~%?;(31Om z7IGtMmgI!*-}y%+kkhEHa6$b}K{|52rzvN=&vK~GdN$z!bo;5;Ezz>Ha@$)^S-R(K zG;j4q@VkhJsdKm}NK=UWpO#dH(A6vXEWrJ*B5v@m@R;IXN-D9dY`qcf@hVq|`3$*h zS)o@ZxV3-fQ_c5XgDK`#TCs|YQxAtxXKy6)bGeg1xe&8_4k0QZ>a%-ie=_|jfz(*q zXPr-yHK0*uE@8~VZVjc^?I5Jf{3Pi-&MHzw_ZDPZjSM(8D8@+@# zH_D5W^anIL;Bf8O3)QbsiY0@bBvYApGiH<_jvkPr&sZ8w{y7@xEq2AjOtd6T_VBba!!@ZrwRQD%FAN`^l*z&xR`ITJKc}b6 zm>x+*oq2AIzEatbm52Mn1QL5(tsS)^1jT!SUVn0Tu(-;{6n<(4QDsvsBVlB}K=-4s zl&p3{C~wER2@u1ErxGXCMs^cJ=J0#P1UJ-fsR{~jH>`-bHYMq7}KDySgnbM}R5;yZ#t68wO08JfKOBK)HFn#9GZ zS|OIwbvZV9ztkj(RK75!2AkzFPn7R~oL;4dfFr^FFu!s!a!KB7)@<86&+)DuTOtl= zG-H4J4m-B0r{GAZWLc6&qetQ=WW=}?&)ra&u>ntPT5b(Vq;@l zV{6otV`VK8)SD*N3Wnr`<+EfpWBCWt;>BX)cw#YQKFMOq+76ghv(Lwos*F%Vq$4|!7q1qjVWS?biOkT;0AtWgz-LHc zpws%LDXnQy%u`HROk7N&iS-k#_9lC+s8`c~AuCm-tT6M()ubxdi&WlkGP(+j3eB?n zdB(f6PME?8U`f&Uwm+~+)TR%nbEh4CGfXR&ICH9^8zY5$AD!#w>uv125t8sk@(ecY zy(4z!6fpNv&H8!tlSP$Pv*Kf0$&*gt&()Ieg=(b}MW>Q@WftYcg-w!(P&LYUm;@yP?u!vQMnap;kEM|-DsXLYfEnMH(+p^Y?vRDe{5 z)D`?I*byU%c;>YT7Zp)eWRUb? z4ybW3w{c2A${|;?DQ22rGTI2^i0!9#f+zy2hzx=^HEcEM=3(YqBYV}Y(K3J8$E(4G7q`whuc5_)gu${r2*`4t$BcE4=z(Z!{xE5QgxEnU-KP(x!JNa>NXhC7mxpP0TA>!KMn1DP!x=kfVrI~e_cd()J`e#@g6CA=?bX}1zlVY&gQJ_Q|)sb3*Gd*oF1NTzTdWa5g!d;MB^qbWEFyjR);q4xKYuv)1IoX5!xU(gbT)Y%PoB88b05GtV1_E&lGM0 z;d#ZwvPI{ao2cC>dYDZW2D8T{v}s9k-6I$zD>PoHyZ-QzMUxYZ;YlQm184b<$qw_; z7lu!D6LL6nIC2P56U4&HU4KsM(Iv!E2+Y4Xjj{GGrH_c>lAqlyJUiYHo<*Ie($x5G42-ou5+B_^0x#a!NvVVI>HSnfEnN%ysU zO2OBH<9e0_kAmS=>ua7hp4L!{9|;+h=vk(nj4~EuA(AtG>H8J3hT_=vlM8kbr+WLG|BogekbhzsUn(uC5J5b2I_Pynm)e-B$^QGU7*mcKAAfaWt zr6s1tn*~j@*+Hw9SahVV&fVLx8X2ZbnI6*K6KZyvJ{LVIVWLZd1Wu)weEV)oW749> zeBy#?b0F>w9&}z*lIFCvQ~_nAOlyb>MC3GLn)RjfZbq?CnTy91@mk6Qr?L6@XjHB2 zr{K|o^nHl_@4FqZgIhtuPL^5&^j~hbvC9SLkc0Y~q$QTDUuzDV8(K5dy;f1PugeAX zMD4p)d&6A%u7cB)dDCq8mo1Nyr|b)-^3MN2vKEt$?2h!B1&?j|phce7&A7jQ9iJa# z3Ca4N+!usk>l6aS(7?mp^cVTI3A*Ln1QS5=pHML|U$%-XHw z^r~I(-nHy)&Rdmb^A_?))f=_-^$wL%6-bt`$aOT!ZNREelBRBwTFQxkN4RFz5yuk&tY_)Cn)v^*}u^I`U^k^%OFEf z#K}pjC;EP0@pp(5&G2)h%Ir@x)h2?Aat72``P}XtJXbTXKzi&TgNVLf>5{(Q)(>w7 zMBuY&T&pX{3E#g2@jtF#7#eIdoM5FK+&-(Xi<+}#`U~G|KhVWFlaBcgX$%37_k*dn zoSBjmhyjq1L2w}!AOt{y1D@Bw0|LE>4FHM_B}D;aTL_DhiS1`o7B`69UlWj!n*bm}Or4A< z+#uFAjskANRR7Qr0OY^LtW*^LP;s&nrqWhYrI4_7Fs0yOVPj#V5<#V)pb&B}F%wXi zl>V1F@JpELi<6U`04uAjt1F8uCyT9vIV(FqKR+uQ2P+2$GoZoj=x*a=2{`1YSp z{_RK7)X~_%!p_OU)`sG*U!%{q&Q8KqRDT=#pU*$zGk6=07d|m!hM&@6}|6TbnLm}3`J^!yv{4>q}Ciid)q1OgG6 z$VrN8y1^Z0xDLPSdF~5=RZ(a?qK4Dt=4KbYLnV^>hW$+q0j)P6JeCO8^Zutc+h@(n zZr@jk_}?(X^u-YYVst?9j_S?#^4jI=x+gPV&pyWnPgl>e`fKX@Kd^&?VRLZJH7sRp z_(6C{m`%ert-1)*9tTRB5O>!(XmZpg}XneHr?#bh_v zm+@={Bt{J!Y=0-bxmt_3`AVa}`CrZ@KZ8%EG0?K`TN0Ud|?)shLzl5^|{68M3Ct$M<;9jn()bDhhZVYKn=W&m|?JR53>IxKMQ7)?$|b?+bID z(E2UyHkRd%8rP#*bt?3lCLbQJms@vZGyR@l)eW&UH%@D}dNr*YY$2kQ={E3O?msVX z?9Y^OmVB(hbojJ3Wl{$Vvu$M1qPWU+?Z%NwCb4aqXt--p@Llcvx>b+_=BQ{nlmGHu zlc!bk;i#>4!`J@la@L@=UNbdYJH_*;>4*ExYRD25kZ&`ehf|qr&#H`xJef3o*QIH> zvxoDsG#PU5TJ5uLQmfxZ-{&oUWaxP&vFPtTjA@dnmBM(huuyc&0B%F>!{x6%SLKXEM`HZ0p*Ex8)djqF#n*&IQyVY$0qd_+~1)ARgruF}AL!)yOW&}H~mWk=?XbNudX zMV9;d2=9{90=wDPaEjT{pC-(31D{(OPTA^D$HQE9VfaevJ4)$%6D<#CL;TYlZ8=%4 zF!!z6DP_m?X#OptuuO&Zt66 zi{95u4GNzcZ1f#>azawOFa?KG&<$fv4j1aoIsy>4)}uv7PrqFrF3xgU%`lncZi+tN zM(-}SwZ$DT+O|P>Z2s&dPSjdxI09)-1G>JbdR5W(WZ8OuB*+OEbmfc3VCN5~p5+|C zpq?bb?m@Tb+i6%LH|-Bk_s9GLkSEE>^)3;TWQq>o1*oTM4UnlckIOxN;l1OwXP9CN zM^C9i8;lm?K?gMsdbKcg(DwYK3@iCi#zF3Lle7E#XB_`o|JxCt`$O~4Jg{xcX|NUA zlbjxL{&_GEI;>QuruX4$(H8SLbq})M5@!Ot+DttGOT?#%8r6=8+&7kEc@0)TuRcYw zPFTfVtxHhg=e|@9!oqpqkNhCk;!oaRq?V_6e{~3*I1&-=k&B~EsWR?MW!9}vJ|7cZ zcD`Gb)c@tgZjB&084jK;*B_bwXaHHLvoeoM%@p<&xQ`%Y<(W0`g}x0q>VOv%9~>5~ zlZqgS%9^-)b9mYj_)^fPoS4&Wt|(oA{kxGA7VQlK^lC9qSzwpiakn5TmBgm8!|U;K zmS^F*Mw2%5kS0lQZR5TROOy}ZafrEr=no-G%=T%)s=}GJ47-c5qGi%^ZociL6D>o< ztMZRiiyj0DwdZ+Zdk=G-)#rDw(5~uq({UI@B-C5^BR2oMm1aAxL7pLeII|DagvrRqD#UrtWtfGabGg5x%xb73$ZRy@u`OhB% z6SZ$elxEzx`TnTc(F59ZRvM2YT)Jq}Jc85m;NiS8-hocc(M_M3h6!1{JazDsLtl#+ zslBg~W`Z7uHg}T{A(A@uQ`*Eo^s$7`9aES8=)>-BCW|&A?h*(t<*78Y-TVFXs>E@) zIe+!nemQ52`It+TUEklz2tWxYIWGb+a%J?1Ehjx02s#bnS#6|U|JaADd+bb?BoPZZ73GKo z8Al7x;27EHS^qg{sc8s>E+0U!XByd(QE0~<&A;VAQPxl^t;m|lmVKYS=`xJV8fP>vQpyJ7(JI;@beLK6{P7ukHpxikT_Nnr z`ik}KuzHAv6FmpzkK)67ZACKo4V(tTD3vP5?a^`&B~NHw1KpPSNB+KDsLq5$=x)cS z8RROAsX`^k$4DOKtW%m8zKL^*fpKJeqnv7toAT1+C@+RZB*7TJ()g0)} zH)|0ML&2GFrjsQ3Nw4d7yzdVi_)WK1HDN{}ZychweT{Ze*Nl3iQE)ue9k+(rH%8c^ z!6xHjk(J4FSJhWrvk}DimJwullsAN@Z*W%vb|%seC-OqLM_MjtbdOqH)>J6$b3w(0G0|{T>pyo0cC#dw z#~a`JFw#2hKsl`GZl=+y%y5G#MWOVbm^I_p7Y`>H>~L&0fA|{c_Nhx(RfrUyZnwz; zpMR6IK;*oz4GOLw9IkP=7(MjN^#t{-kjdeiMSyMIp=Q<{2fP3AQpQm1r_|d1?M_~_ zgR%tjsVT;5uKqCFKaEwpQ$M25C1?VgIi4u*C5+~J+2&vaV?yVO=DQNgg>bEINx4j) zYTuck>ov@C6gw*oxyviD-z}tG@kTpI8fBirA_xIEiN0!6dfgaUkM#-;zvi>cW^}uO zmjBvgkg)AUVobd999NlHo1X`hZqN7pl!9rq5;T=qgb0XFcr`yg5~6Y1c}~m#F2KaW7>o` z`)ENH>mkax$JIm+&bO`N4;H`)k?sC)+Lyun{E$A3>P5eu4zDv!zoU&9hHR{{LWCliy{k+=M1+mB z6{@hZMy==NgB62s!s@U8JY3cF72#RIHGNnyQ_I~!L$tL`dd2*_oJrmgW24yX+!l=F|wb=Yh5hL z+q)z@9;ZIJA0zAVg$dHvn+4Y+IrTVKx||DQgy4S?!vdauWcfJgf<)u-1$;_{()RVE zL;UQox9s!ivb-jRqjM_pC8+qL`=(Ht=M5bKU{w2?XHzF#*}U!WTiMVS3e#V`!L?lh zmNV>Y(KQ_$*_cd&X6&}(Vab+kRW8#Pa?RCZ_uE;%<-2whd~H{CbW?%XNMv#~xjWxx zeI8JL-yY*3KbIGlwSO?HXe3#v1LK=$O=}SMa{S4c5vz#lD-9TJVK41Sl8DB(KOa^9 zlqVNx5DG?8klT^kM9Wbk+2E8-5kI4+!4NjlLttAg^z)n6oXvI*9_g;oTL>ZJJ(MT0 z(?>$_Db*PpKn0PwRY9H@;5`+yR&fl2{D;D>P#8_*FFFGXy{g6i3It1!^Jj;GqBDB7 zLi!_pRmDe;%JzO&zs^l!$O1c8GR~Ib8rt)WDQ>?cyNp}e)Q}r>4FfwY$R|oG`k6<& zSCBpFe%-*0*`aATG;vQrVe{{*nH+{ckF36SKVl#(J1CJA%l#`-iUCo5 zI=wg&U{B<5VVUHVZwTRVWbxs`z{RN=2{`}PL5meVW0V2Q-)Hl$e?Mdd+RD#8;q4ZI zwCGwFiA*}UwR#13a6t63$Y0z@P+agC;Hj|xHmA9vRSpD14P@ZE;l~^xFKR&lq@=)` z;eN=H@5OQn|DP!%UwV6}X}#YJ7ZdRpaNbM4yWIZ=sMTHi<&0r(9Rzfje{rTvcLd;# z=@4LVQr7bU-(|7@1L0I>zGXF)E&jr|;WJw)h$=WV4`&J5 z;mOSxQRmYy-NJxdIKSlCtW3>xZ}YiA7)ppziJ)%*T+ zJ)6WFj|W6y;c}%;xcnX$np~$i$#Q17FqEbWsEByqB?ELNWMOaai-8WlW$*JyX{uLO zJvaSr!QIyv8G^1n`pquR3jvS(j$7To^i6DS&Vz*ACw^+sO%*5*{=mN49!X2mHAGyl zO!%n&l{nT#I7sK(aw}mk6Sn&!>!|wtsQEkl;_1T9Qglj6IX{>LdT$ee>e_j=nc*-< zUoHjn=B*CMsD$*qN*>J+oMqCf?w+q`7s}Y9jQKRNK0-v7ZWo}dbrlw26$4#tP)vS5 zS*pX?H9muM6^6zbWWmTYzrXsG5X=UQwPX1vpFx|Eed#x^J*Q)sw{8LA=^h09Ypz(U zM81aM){7+wU@ECDhYS4MX9K;6BPZ=7M6hz*hEXPP%iKI)?^7f(=P*ENpmHVkte$~+ zkcr%{t0TPy2n$e|XtF}3G@ellfV#U}g_+ClW1!nMktMR|TLLqCi}dsJl!~bH8r0(P z%Kh*mmD}ne7UJ46cRAkb?aAqOVz`|SoHYx70nORIYcr}QKd|!gM-RM{MW(v%FR&T? z{?Y)#Hhl5@?e(C)WPS!A`pks&RlrvV7AMKbb0kdC9bMU0WQWeVDq{r#p;5-Z52HhE zWM(6&o9O0E#Ex5%{Cj^qr|UUL?WKojiv>@(-*VtL zL9g+b9k=$8r2LZr=`5I0N`!XAB;6+y3OD{`tTs!(5Hlb3+kBu+0O%O5wU`PZPD(GoDS(vBrNHT$=8`oZN7z4D#!=bc~CMv@GCEC3q3$>@a)Q{r7R zF&oP~wAK9nYBQWnXJOxHJX<`f*$iMgTUowO1N7f=PIe~pM#wx5aBeTtjJ5IGG$k(N z9g{Vy9E?mx>f;aB`C| zVIdrH<&jP3db^oqYtCX%8ZmU)oZ*iWCY!L+L24iipft3#Z6b>`Toc>XL}UrsTiP z8jG$>ZH>;D0*kqv4(8bD&=T`1-2=VlyGTCn`4H8s7edK^i=HjT(m6RyTAk4ILi^So zl>r3YHWQk*-Vfs^OY9@ZP(iTc2>0lT9~aYIq(lt#t1cxvWtFh7m#cAaq&*#DC^>My z!+Bxqd$uv~6$8bN8eNyZ7mO8})?payF6ZAPInyT zUwp$#^Z9abnijncJXs#1PLvQ=|2iU0gU*8qc9B+u99x)<#IT)CQI8;}p*+8tj@O+? zVG)oxvM?qOtL=;<_QAs!pL|u5M2Jy&Kof}_q#wg&G>01^MD1b!1#w=r1OdCHhyePs z>|Fri70HKy#EdJaX>90kC6oL|s{T)p*m0E)rqhNY5Zo5am@;rCE_AO-Ym^eFXhSX} zM)6&;QaXh3i&HO_>;;Dp9Cgm!MLv%$j8ssgN)FmG#~o22jw@^+DUbaUo`lyXGLV>` zr+cE1^u=X?G_ae$u6oB|gK&~@dg87JU*A1HOGKz_Un`MI*!SVP5;yKj*<1UQ93zP* z5>-ns?eA5<^=5==O@GmQ1R`~5dcOEFBS_ASoOFvMEAb&Uqc8%|C+UgxykS0!Dq-1L zU3lOKL8*-A5<%F3Oc#RG5#!30*(8i&(81WxGf47pWWJ0W( z%Ku6e`=AmAK3im);uec^dlZc>;kNwUe>SN8s+53P@@A$*u{2gz^ELLsQmcQURz) z0haiyQv(3}kps}8!l#Gde>S(#eK;NPJWe{buCl30^Y zv?e(OJ|b|)f|yq~D_R~GT5q_m(Q`t{q>W0sW5{hXDG32!3+0{QJL=oRqR;`Fq2F{{({ + 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..43d3cbe95a --- /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, + Rating, + Response, +} from '@backstage/plugin-entity-feedback-common'; + +import { EntityFeedbackApi } from './EntityFeedbackApi'; + +/** + * @public + */ +export class EntityFeedbackClient implements EntityFeedbackApi { + private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; + + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) { + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi; + } + + async getAllRatings(): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch(`${baseUrl}/ratings`, { + method: 'GET', + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async getOwnedRatings(ownerRef: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings?ownerRef=${encodeURIComponent(ownerRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async recordRating(entityRef: string, rating: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ rating }), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async getRatings(entityRef: string): Promise[]> { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async recordResponse( + entityRef: string, + response: Omit, + ) { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/responses/${encodeURIComponent(entityRef)}`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify(response), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async getResponses( + entityRef: string, + ): Promise[]> { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/responses/${encodeURIComponent(entityRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } +} diff --git a/plugins/entity-feedback/src/api/index.ts b/plugins/entity-feedback/src/api/index.ts new file mode 100644 index 0000000000..32c8624425 --- /dev/null +++ b/plugins/entity-feedback/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './EntityFeedbackClient'; +export * from './EntityFeedbackApi'; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx new file mode 100644 index 0000000000..b639b2e727 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackRatingsTable } from './FeedbackRatingsTable'; + +describe('FeedbackRatingsTable', () => { + const sampleRatings = [ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo Component', + ratings: { 'Rating 1': 3, 'Rating 2': 1 }, + }, + { + entityRef: 'system:default/bar', + entityTitle: 'Bar Component', + ratings: { 'Rating 1': 5 }, + }, + { + entityRef: 'domain:default/hello-world', + entityTitle: 'Hello World', + ratings: { 'Rating 3': 5 }, + }, + ]; + + const feedbackApi: Partial = { + getAllRatings: jest.fn().mockImplementation(async () => sampleRatings), + getOwnedRatings: jest.fn().mockImplementation(async () => sampleRatings), + }; + + const sampleRatingValues = ['Rating 1', 'Rating 2']; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all ratings correctly', async () => { + const rendered = await render({ allEntities: true }); + + expect(feedbackApi.getAllRatings).toHaveBeenCalled(); + expect(feedbackApi.getOwnedRatings).not.toHaveBeenCalled(); + + expect(rendered.getByText('Entity Ratings')).toBeInTheDocument(); + + // Columns + expect(rendered.getByText('Entity')).toBeInTheDocument(); + expect(rendered.getByText('Rating 1')).toBeInTheDocument(); + expect(rendered.getByText('Rating 2')).toBeInTheDocument(); + + // Rows + expect(rendered.getByText('Foo Component')).toBeInTheDocument(); + expect(rendered.getByText('component')).toBeInTheDocument(); + expect(rendered.getByText('3')).toBeInTheDocument(); + expect(rendered.getByText('1')).toBeInTheDocument(); + expect(rendered.getByText('Bar Component')).toBeInTheDocument(); + expect(rendered.getByText('system')).toBeInTheDocument(); + expect(rendered.getByText('5')).toBeInTheDocument(); + + expect(rendered.queryByText('Hello World')).toBeNull(); + }); + + it('renders owned entity ratings correctly', async () => { + const rendered = await render({ + ownerRef: 'group:default/test-team', + title: 'Custom Title', + }); + + expect(feedbackApi.getAllRatings).not.toHaveBeenCalled(); + expect(feedbackApi.getOwnedRatings).toHaveBeenCalledWith( + 'group:default/test-team', + ); + + expect(rendered.getByText('Custom Title')).toBeInTheDocument(); + + // Columns + expect(rendered.getByText('Entity')).toBeInTheDocument(); + expect(rendered.getByText('Rating 1')).toBeInTheDocument(); + expect(rendered.getByText('Rating 2')).toBeInTheDocument(); + + // Rows + expect(rendered.getByText('Foo Component')).toBeInTheDocument(); + expect(rendered.getByText('component')).toBeInTheDocument(); + expect(rendered.getByText('3')).toBeInTheDocument(); + expect(rendered.getByText('1')).toBeInTheDocument(); + expect(rendered.getByText('Bar Component')).toBeInTheDocument(); + expect(rendered.getByText('system')).toBeInTheDocument(); + expect(rendered.getByText('5')).toBeInTheDocument(); + + expect(rendered.queryByText('Hello World')).toBeNull(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx new file mode 100644 index 0000000000..48b2018729 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityRef } from '@backstage/catalog-model'; +import { ErrorPanel, SubvalueCell, Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { entityFeedbackApiRef } from '../../api'; + +interface FeedbackRatingsTableProps { + allEntities?: boolean; + ownerRef?: string; + ratingValues: string[]; + title?: string; +} + +export const FeedbackRatingsTable = (props: FeedbackRatingsTableProps) => { + const { + allEntities, + ownerRef, + ratingValues, + title = 'Entity Ratings', + } = props; + const feedbackApi = useApi(entityFeedbackApiRef); + + const { + error, + loading, + value: ratings, + } = useAsync(async () => { + if (allEntities) { + return feedbackApi.getAllRatings(); + } + + if (!ownerRef) { + return []; + } + + return feedbackApi.getOwnedRatings(ownerRef); + }, [allEntities, feedbackApi, ownerRef]); + + const columns = [ + { title: 'Title', field: 'entityTitle', hidden: true, searchable: true }, + { + title: 'Entity', + field: 'entityRef', + highlight: true, + customSort: (a: EntityRatingsData, b: EntityRatingsData) => { + const titleA = a.entityTitle ?? parseEntityRef(a.entityRef).name; + const titleB = b.entityTitle ?? parseEntityRef(b.entityRef).name; + return titleA.localeCompare(titleB); + }, + render: (rating: EntityRatingsData) => { + const compoundRef = parseEntityRef(rating.entityRef); + return ( + + } + subvalue={compoundRef.kind} + /> + ); + }, + }, + ...ratingValues.map(ratingVal => ({ + title: ratingVal, + field: `ratings.${ratingVal}`, + })), + ]; + + // Exclude entities that don't have applicable ratings + const ratingsRows = ratings?.filter(r => + Object.keys(r.ratings).some(v => ratingValues.includes(v)), + ); + + if (error) { + return ( + + ); + } + + return ( + + columns={columns} + data={ratingsRows ?? []} + isLoading={loading} + options={{ + emptyRowsWhenPaging: false, + loadingType: 'linear', + pageSize: 20, + pageSizeOptions: [20, 50, 100], + paging: true, + showEmptyDataSourceMessage: !loading, + }} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts new file mode 100644 index 0000000000..565063b3fa --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './FeedbackRatingsTable'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx new file mode 100644 index 0000000000..631738d6f9 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { getByRole, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackResponseDialog } from './FeedbackResponseDialog'; + +describe('FeedbackResponseDialog', () => { + const testEntity: Partial = { + kind: 'component', + metadata: { name: 'test', namespace: 'default' }, + }; + const errorApi: Partial = { post: jest.fn() }; + const feedbackApi: Partial = { + recordResponse: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows customization of the dialog title', async () => { + const rendered = await render(); + expect( + rendered.getByText('Please provide feedback on what can be improved'), + ).toBeInTheDocument(); + + const customRendered = await render({ feedbackDialogTitle: 'Test Title' }); + expect(customRendered.getByText('Test Title')).toBeInTheDocument(); + }); + + it('allows customization of the reponse options', async () => { + const rendered = await render(); + expect(rendered.getByText('Incorrect info')).toBeInTheDocument(); + expect(rendered.getByText('Missing info')).toBeInTheDocument(); + expect( + rendered.getByText('Other (please specify below)'), + ).toBeInTheDocument(); + + const customResponses = [ + { id: 'foo', label: 'Foo option' }, + { id: 'bar', label: 'Bar option' }, + ]; + const customRendered = await render({ + feedbackDialogResponses: customResponses, + }); + expect(customRendered.getByText('Foo option')).toBeInTheDocument(); + expect(customRendered.getByText('Bar option')).toBeInTheDocument(); + }); + + it('handles saving user responses', async () => { + const rendered = await render(); + + await userEvent.click( + rendered.getByRole('checkbox', { name: 'Incorrect info' }), + ); + await userEvent.click( + rendered.getByRole('checkbox', { name: 'Other (please specify below)' }), + ); + await userEvent.type( + getByRole( + rendered.getByTestId('feedback-response-dialog-comments-input'), + 'textbox', + ), + 'test comments', + ); + await userEvent.click( + rendered.getByTestId('feedback-response-dialog-submit-button'), + ); + + await waitFor(() => { + expect(feedbackApi.recordResponse).toHaveBeenCalledWith( + 'component:default/test', + { + comments: 'test comments', + consent: true, + response: 'incorrect,other', + }, + ); + }); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx new file mode 100644 index 0000000000..c3f935e432 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx @@ -0,0 +1,176 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core-components'; +import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Grid, + makeStyles, + Switch, + TextField, + Typography, +} from '@material-ui/core'; +import React, { ReactNode, useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { entityFeedbackApiRef } from '../../api'; + +/** + * @public + */ +export interface EntityFeedbackResponse { + id: string; + label: string; +} + +const defaultFeedbackResponses: EntityFeedbackResponse[] = [ + { id: 'incorrect', label: 'Incorrect info' }, + { id: 'missing', label: 'Missing info' }, + { id: 'other', label: 'Other (please specify below)' }, +]; + +/** + * @public + */ +export interface FeedbackResponseDialogProps { + entity: Entity; + feedbackDialogResponses?: EntityFeedbackResponse[]; + feedbackDialogTitle?: ReactNode; + open: boolean; + onClose: () => void; +} + +const useStyles = makeStyles({ + contactConsent: { + marginTop: '5px', + }, +}); + +export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => { + const { + entity, + feedbackDialogResponses = defaultFeedbackResponses, + feedbackDialogTitle = 'Please provide feedback on what can be improved', + open, + onClose, + } = props; + const classes = useStyles(); + const errorApi = useApi(errorApiRef); + const feedbackApi = useApi(entityFeedbackApiRef); + const [responseSelections, setResponseSelections] = useState( + Object.fromEntries(feedbackDialogResponses.map(r => [r.id, false])), + ); + const [comments, setComments] = useState(''); + const [consent, setConsent] = useState(true); + + const [{ loading: saving }, saveResponse] = useAsyncFn(async () => { + try { + await feedbackApi.recordResponse(stringifyEntityRef(entity), { + comments, + consent, + response: Object.keys(responseSelections) + .filter(id => responseSelections[id]) + .join(','), + }); + onClose(); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, [comments, consent, entity, feedbackApi, onClose, responseSelections]); + + return ( + !saving && onClose()}> + {saving && } + {feedbackDialogTitle} + + + Choose all that applies + + {feedbackDialogResponses.map(response => ( + + setResponseSelections({ + ...responseSelections, + [e.target.name]: e.target.checked, + }) + } + /> + } + label={response.label} + /> + ))} + + + + setComments(e.target.value)} + variant="outlined" + value={comments} + /> + + + Can we reach out to you for more info? + + No + + setConsent(e.target.checked)} + /> + + Yes + + + + + + + + + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts new file mode 100644 index 0000000000..8ce88f4b87 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './FeedbackResponseDialog'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx new file mode 100644 index 0000000000..c5d3d51884 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackResponseTable } from './FeedbackResponseTable'; + +describe('FeedbackResponseTable', () => { + const sampleResponses = [ + { + userRef: 'user:default/foo', + consent: true, + response: 'resp1,resp2', + comments: 'test comment 1', + }, + { + userRef: 'user:default/bar', + consent: false, + response: 'resp3,resp4', + comments: 'test comment 2', + }, + ]; + + const feedbackApi: Partial = { + getResponses: jest.fn().mockImplementation(async () => sampleResponses), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all responses correctly', async () => { + const rendered = await render(); + + expect(feedbackApi.getResponses).toHaveBeenCalledWith( + 'component:default/test', + ); + + expect(rendered.getByText('Entity Responses')).toBeInTheDocument(); + + expect(rendered.getByText('foo')).toBeInTheDocument(); + expect(rendered.getByText('resp1')).toBeInTheDocument(); + expect(rendered.getByText('resp2')).toBeInTheDocument(); + expect(rendered.getByText('test comment 1')).toBeInTheDocument(); + expect(rendered.getByText('bar')).toBeInTheDocument(); + expect(rendered.getByText('resp3')).toBeInTheDocument(); + expect(rendered.getByText('resp4')).toBeInTheDocument(); + expect(rendered.getByText('test comment 2')).toBeInTheDocument(); + }); + + it('renders a custom title correctly', async () => { + const rendered = await render({ title: 'Custom Title' }); + expect(rendered.getByText('Custom Title')).toBeInTheDocument(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx new file mode 100644 index 0000000000..2bc03b8537 --- /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 { Response } 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 1621cdc5b1..148b33fa81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5845,6 +5845,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" @@ -11211,7 +11277,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: @@ -13595,7 +13661,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: @@ -22190,6 +22256,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:^" @@ -22291,6 +22358,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:^"