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