Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/feedback-notification-links.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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');
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export class DatabaseHandler {
comments: response.comments,
consent: response.consent,
user_ref: response.userRef,
link: response.link,
});
}

Expand All @@ -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,
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];

Expand Down Expand Up @@ -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')
Expand All @@ -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);
Expand All @@ -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')
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -247,6 +249,7 @@ export async function createRouter(
comments,
consent,
userRef: credentials.principal.userEntityRef,
link,
});

res.status(201).end();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface FeedbackResponse {
// (undocumented)
entityRef: string;
// (undocumented)
link?: string;
// (undocumented)
response?: string;
// (undocumented)
userRef: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface FeedbackResponse {
comments?: string;
consent?: boolean;
userRef: string;
link?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +49,9 @@ describe('FeedbackResponseDialog', () => {
onClose={jest.fn()}
/>
</TestApiProvider>,
{
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
},
);

beforeEach(() => {
Expand Down Expand Up @@ -106,6 +110,7 @@ describe('FeedbackResponseDialog', () => {
comments:
'{"responseComments":{},"additionalComments":"test comments"}',
consent: true,
link: '/catalog/default/component/test',
response: 'incorrect,other',
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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])),
);
Expand All @@ -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(
Expand All @@ -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 (
<Dialog open={open} onClose={() => !saving && onClose()}>
Expand Down