diff --git a/.changeset/feedback-notification-links.md b/.changeset/feedback-notification-links.md new file mode 100644 index 0000000000..7c20b9b343 --- /dev/null +++ b/.changeset/feedback-notification-links.md @@ -0,0 +1,7 @@ +--- +'@backstage-community/plugin-entity-feedback-backend': patch +'@backstage-community/plugin-entity-feedback': patch +'@backstage-community/plugin-entity-feedback-common': patch +--- + +Add clickable link to feedback notifications. When entity owners receive notifications about new feedback, the notification now includes a link to navigate directly to the entity page. The entity URL is derived from the frontend routing configuration using the same logic as `EntityRefLink`, ensuring it always matches the actual routes configured in the app without requiring additional backend configuration. diff --git a/workspaces/entity-feedback/plugins/entity-feedback-backend/migrations/20251223000000_add_link_to_responses.js b/workspaces/entity-feedback/plugins/entity-feedback-backend/migrations/20251223000000_add_link_to_responses.js new file mode 100644 index 0000000000..536c9800c1 --- /dev/null +++ b/workspaces/entity-feedback/plugins/entity-feedback-backend/migrations/20251223000000_add_link_to_responses.js @@ -0,0 +1,35 @@ +/* + * 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.alterTable('responses', table => { + table.text('link').comment('The entity URL link'); + }); +}; + +/** + * @param { import("knex").Knex } knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('responses', table => { + table.dropColumn('link'); + }); +}; diff --git a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts index c6cde95b3c..359ea0bb67 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts +++ b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts @@ -294,12 +294,14 @@ describe('DatabaseHandler', () => { response: 'asdf', comments: 'here is new feedback', consent: false, + link: null, }, { userRef: 'user:default/bar', response: 'noop', comments: 'here is different feedback', consent: true, + link: null, }, ]), ); diff --git a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts index 4b9f64ad5f..b95d6931f7 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts +++ b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts @@ -129,6 +129,7 @@ export class DatabaseHandler { comments: response.comments, consent: response.consent, user_ref: response.userRef, + link: response.link, }); } @@ -153,12 +154,13 @@ export class DatabaseHandler { ).andOn('responses.timestamp', '=', 'latest_responses.timestamp'); }, ) - .select('responses.user_ref', 'response', 'comments', 'consent') + .select('responses.user_ref', 'response', 'comments', 'consent', 'link') ).map(response => ({ userRef: response.user_ref, response: response.response, comments: response.comments, consent: Boolean(response.consent), + link: response.link, })); } } diff --git a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.test.ts b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.test.ts index d23f76e7f1..40455fe6c6 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.test.ts +++ b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.test.ts @@ -88,18 +88,21 @@ const mockResponses = [ response: 'asdf', comments: 'here is new feedback', consent: false, + link: '/catalog/default/component/foo', }, { userRef: 'user:default/bar', response: 'noop', comments: 'here is different feedback', consent: true, + link: '/catalog/default/component/bar', }, { userRef: 'user:default/test', response: 'err', comments: 'no comment', consent: false, + link: '/catalog/default/component/test', }, ]; @@ -311,6 +314,7 @@ describe('createRouter', () => { response: 'blah', comments: '{ "additionalComments": "feedback" }', consent: true, + link: '/catalog/default/component/service', }; const response = await request(app) .post('/responses/component%3Adefault%2Fservice') @@ -329,6 +333,7 @@ describe('createRouter', () => { payload: { title: 'New feedback for component:default/service', description: 'Comments: feedback', + link: '/catalog/default/component/service', }, }); expect(response.status).toEqual(201); @@ -339,6 +344,7 @@ describe('createRouter', () => { response: 'blah', comments: '{ "additionalComments": "feedback" }', consent: true, + link: '/catalog/default/component/service', }; const response = await request(app) .post('/responses/component:default/service') @@ -357,6 +363,7 @@ describe('createRouter', () => { payload: { title: 'New feedback for component:default/service', description: 'Comments: feedback', + link: '/catalog/default/component/service', }, }); expect(response.status).toEqual(201); diff --git a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.ts b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.ts index a40826921b..98af781f3c 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.ts +++ b/workspaces/entity-feedback/plugins/entity-feedback-backend/src/service/router.ts @@ -207,7 +207,7 @@ export async function createRouter( }); router.post('/responses/:entityRef(*)', async (req, res) => { - const { response, comments, consent } = req.body; + const { response, comments, consent, link } = req.body; const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const { token } = await auth.getPluginRequestToken({ onBehalfOf: credentials, @@ -227,9 +227,11 @@ export async function createRouter( type: 'entity', entityRef: entityOwner, }; + const payload: NotificationPayload = { title: `New feedback for ${req.params.entityRef}`, description: `Comments: ${JSON.parse(comments).additionalComments}`, + link: link, }; await notificationService.send({ recipients, @@ -247,6 +249,7 @@ export async function createRouter( comments, consent, userRef: credentials.principal.userEntityRef, + link, }); res.status(201).end(); diff --git a/workspaces/entity-feedback/plugins/entity-feedback-common/report.api.md b/workspaces/entity-feedback/plugins/entity-feedback-common/report.api.md index cfbb881153..ddc2807c47 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-common/report.api.md +++ b/workspaces/entity-feedback/plugins/entity-feedback-common/report.api.md @@ -22,6 +22,8 @@ export interface FeedbackResponse { // (undocumented) entityRef: string; // (undocumented) + link?: string; + // (undocumented) response?: string; // (undocumented) userRef: string; diff --git a/workspaces/entity-feedback/plugins/entity-feedback-common/src/index.ts b/workspaces/entity-feedback/plugins/entity-feedback-common/src/index.ts index 247a5a5d54..1c4448301d 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback-common/src/index.ts +++ b/workspaces/entity-feedback/plugins/entity-feedback-common/src/index.ts @@ -38,6 +38,7 @@ export interface FeedbackResponse { comments?: string; consent?: boolean; userRef: string; + link?: string; } /** diff --git a/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx b/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx index b1afc04d12..7d3a6ba392 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx +++ b/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx @@ -16,6 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { getByRole, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -48,6 +49,9 @@ describe('FeedbackResponseDialog', () => { onClose={jest.fn()} /> , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, ); beforeEach(() => { @@ -106,6 +110,7 @@ describe('FeedbackResponseDialog', () => { comments: '{"responseComments":{},"additionalComments":"test comments"}', consent: true, + link: '/catalog/default/component/test', response: 'incorrect,other', }, ); diff --git a/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx b/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx index e1c3e94ca7..d4686eccec 100644 --- a/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx +++ b/workspaces/entity-feedback/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx @@ -16,7 +16,16 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Progress } from '@backstage/core-components'; -import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { + ErrorApiError, + errorApiRef, + useApi, + useRouteRef, +} from '@backstage/core-plugin-api'; +import { + entityRouteParams, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; import Button from '@material-ui/core/Button'; import Checkbox from '@material-ui/core/Checkbox'; import Collapse from '@material-ui/core/Collapse'; @@ -104,6 +113,7 @@ export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => { const classes = useStyles(); const errorApi = useApi(errorApiRef); const feedbackApi = useApi(entityFeedbackApiRef); + const entityRoute = useRouteRef(entityRouteRef); const [responseSelections, setResponseSelections] = useState( Object.fromEntries(feedbackDialogResponses.map(r => [r.id, false])), ); @@ -113,6 +123,11 @@ export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => { }); const [consent, setConsent] = useState(true); + // Derive the entity URL using the same logic as EntityRefLink + const entityUrl = entityRoute( + entityRouteParams(entity, { encodeParams: true }), + ); + const [{ loading: saving }, saveResponse] = useAsyncFn(async () => { // filter out responses that were not selected const filteredResponseComments = Object.entries( @@ -135,12 +150,21 @@ export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => { response: Object.keys(responseSelections) .filter(id => responseSelections[id]) .join(','), + link: entityUrl, }); onClose(); } catch (e) { errorApi.post(e as ErrorApiError); } - }, [comments, consent, entity, feedbackApi, onClose, responseSelections]); + }, [ + comments, + consent, + entity, + entityUrl, + feedbackApi, + onClose, + responseSelections, + ]); return ( !saving && onClose()}>