diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdbb59aa06..61d7e2299c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -32,3 +32,5 @@ yarn.lock @janus-idp/maintainers-plugins /plugins/orchestrator @janus-idp/maintainers-plugins @caponetto @jkilzi /plugins/orchestrator-backend @janus-idp/maintainers-plugins @caponetto @jkilzi /plugins/orchestrator-common @janus-idp/maintainers-plugins @caponetto @jkilzi +/plugins/feedback @janus-idp/maintainers-plugins @yashoswalyo @riginoommen @deshmukhmayur +/plugins/feedback-backend @janus-idp/maintainers-plugins @yashoswalyo @riginoommen @deshmukhmayur diff --git a/plugins/feedback-backend/.eslintrc.js b/plugins/feedback-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/feedback-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/feedback-backend/OWNERS b/plugins/feedback-backend/OWNERS new file mode 100644 index 0000000000..ac83ec4203 --- /dev/null +++ b/plugins/feedback-backend/OWNERS @@ -0,0 +1,8 @@ +approvers: + - yashoswalyo + - riginoommen + - deshmukhmayur +reviewers: + - yashoswalyo + - riginoommen + - deshmukhmayur diff --git a/plugins/feedback-backend/README.md b/plugins/feedback-backend/README.md new file mode 100644 index 0000000000..206265d3f6 --- /dev/null +++ b/plugins/feedback-backend/README.md @@ -0,0 +1,91 @@ +# Feedback Backend + +This is feedback-backend plugin which provides Rest API to create feedbacks. + +It is also repsonsible for creating JIRA tickets, + +## Getting started + +### Installation + +1. Install the backend plugin. + + ```bash + # From your backstage root directory + yarn workspace backend add @janus-idp/backstage-plugin-feedback-backend + ``` + +2. Then create a new file `packages/backend/src/plugins/feedback.ts` and add the following: + + ```ts + import { Router } from 'express'; + + import { createRouter } from '@janus-idp/backstage-plugin-feedback-backend'; + + import { PluginEnvironment } from '../types'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { + return await createRouter({ + logger: env.logger, + config: env.config, + discovery: env.discovery, + }); + } + ``` + +3. Next we wire this into overall backend router, edit `packages/backend/src/index.ts`: + + ```ts + import feedback from './plugins/feedback'; + + // ... + async function main() { + // ... + const feedbackEnv = useHotMemoize(module, () => createEnv('feedback')); + apiRouter.use('/feedback', await feedback(feedbackEnv)); + } + ``` + +4. Now add below config in your `app-config.yaml` file. + + ```yaml + feedback: + integrations: + jira: + # Under this object you can define multiple jira hosts + - host: ${JIRA_HOST_URL} + token: ${JIRA_TOKEN} + + email: + ## Email integration uses nodemailer to send emails + host: ${EMAIL_HOST} + port: ${EMAIL_PORT} # defaults to 587, if not found + + ## Email address of sender + from: ${EMAIL_FROM} + + ## [optional] Authorization using user and password + auth: + user: ${EMAIL_USER} + pass: ${EMAIL_PASS} + + # boolean + secure: false + + # Path to ca certificate if required by mail server + caCert: ${NODE_EXTRA_CA_CERTS} + ``` + +### Set up frontend plugin + +Follow instructions provided [feedback-plugin](../feedback/README.md) + +### API reference + +The API specifications file can be found at [docs/openapi3_0](./docs/openapi3_0.yaml) + +### Run + +1. Now run `yarn workspace @janus-idp/backstage-plugin-feedback-backedn start-backend`. diff --git a/plugins/feedback-backend/app-config.janus-idp.yaml b/plugins/feedback-backend/app-config.janus-idp.yaml new file mode 100644 index 0000000000..ca89476893 --- /dev/null +++ b/plugins/feedback-backend/app-config.janus-idp.yaml @@ -0,0 +1,25 @@ +feedback: + integrations: + jira: + # Under this object you can define multiple jira hosts + - host: ${JIRA_HOST_URL} + token: ${JIRA_TOKEN} + + email: + ## Email integration uses nodemailer to send emails + host: ${EMAIL_HOST} + port: ${EMAIL_PORT} # defaults to 587, if not found + + # Email address of sender + from: ${EMAIL_FROM} + + ## Authorization using user and password + auth: + user: ${EMAIL_USER} + pass: ${EMAIL_PASS} + + # boolean + secure: false + + # Path to ca certificate if required by mail server + caCert: ${NODE_EXTRA_CA_CERTS} diff --git a/plugins/feedback-backend/config.d.ts b/plugins/feedback-backend/config.d.ts new file mode 100644 index 0000000000..37597a2b52 --- /dev/null +++ b/plugins/feedback-backend/config.d.ts @@ -0,0 +1,71 @@ +/** + * Configuration options for the application. + */ +export interface Config { + feedback?: { + integrations: { + /** + * Configuration options for JIRA integration. + * It is an array, which can be used to set up multiple jira servers at the same time. + */ + jira?: Array<{ + /** + * The hostname or URL of the JIRA organization. + */ + host: string; + + /** + * The access token for authenticating with JIRA. + */ + token: string; + }>; + + /** + * Configuration options for email integration. + */ + email?: { + /** + * The SMTP server's hostname or IP address. + */ + host: string; + + /** + * The port number to use for the SMTP server. + */ + port: number; + + /** + * Optional authentication settings for the SMTP server. + */ + auth?: { + /** + * The username to use for SMTP server authentication. + */ + user?: string; + + /** + * The password to use for SMTP server authentication. + * @visibility secret + */ + pass?: string; + }; + + /** + * Set to `true` if you want to use SSL/TLS for the SMTP connection. + * Default is `false`. + */ + secure?: boolean; + + /** + * The email address from which emails will be sent. + */ + from?: string; + + /** + * Path to a custom CA certificate file. + */ + caCert?: string; + }; + }; + }; +} diff --git a/plugins/feedback-backend/docs/openapi3_0.yaml b/plugins/feedback-backend/docs/openapi3_0.yaml new file mode 100644 index 0000000000..6c0d52c9e2 --- /dev/null +++ b/plugins/feedback-backend/docs/openapi3_0.yaml @@ -0,0 +1,351 @@ +openapi: 3.0.3 +info: + title: Feedback Backend Plugin REST API + description: This is a sample description about this spec. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: '3' +servers: + - url: https://backedn-url/api/feedback +tags: &ref_2 [] +paths: + /: + get: + summary: Get all feedbacks + description: 'Returns all feedbacks from database ' + operationId: getAllFeedbacks + tags: *ref_2 + parameters: + - in: query + name: query + description: Search text + schema: &ref_0 + type: string + - in: query + name: projectId + description: Fetch feedbacks for specific component + schema: *ref_0 + required: false + - in: query + name: limit + description: Number of feedbacks to fetch + schema: &ref_1 + type: number + required: true + - in: query + name: offset + description: Value of last feedback of current page + schema: *ref_1 + required: true + requestBody: &ref_3 {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Feedback Model' + description: Array of all feedbacks + count: + type: number + description: Total feedbacks in database + currentPage: + type: number + description: Feedbacks fetched in current request + pageSize: + type: number + description: Last feedback fetched from database + required: + - data + - count + - currentPage + - pageSize + description: Get Array of all feedbacks + post: + summary: Create new feedback + description: Creates feedback and creates ticket on Jira, Github, etc + operationId: createNewFeedback + tags: *ref_2 + parameters: &ref_5 [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Feedback Model' + responses: + '201': + description: Feedback created successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Feedback created successfully + data: + type: object + properties: + feedbackId: + type: string + description: Unique id of newly created feedback + projectId: + type: string + description: Entity ref of component + required: + - feedbackId + - projectId + required: + - message + - data + '500': + description: Error occured + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /{feedbackId}: + get: + summary: Get individual feedback + description: '' + operationId: getFeedbackById + tags: *ref_2 + parameters: [] + requestBody: *ref_3 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Feedback Model' + description: Feedback details + message: + type: string + description: Feedback fetched successfully + required: + - data + - message + description: Feedback fetched successfully + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Feedback id is invalid + description: Feedback id is invalid + patch: + summary: Update the feedback in database + description: '' + operationId: updateFeedbackById + tags: *ref_2 + parameters: *ref_5 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Feedback Model' + description: Modal data that need to be updated + responses: + '200': + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Feedback Model' + description: Object of updated data + message: + type: string + description: Feedback updated successfully + required: + - data + - message + description: Feedback updated successfully + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Feedback id is invalid + description: Feedback id is invalid + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Failed to update data + description: Failed to update data + delete: + summary: Delete the feedback from database + description: '' + operationId: deleteFeedbackById + tags: *ref_2 + parameters: *ref_5 + requestBody: *ref_3 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + Message: + type: string + description: Deleted successfully + required: + - Message + description: Feedback Deleted successfully + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Feedback id is invalid + description: Feedback id is invalid + /{feedbackId}/ticket: + get: + summary: Get details like progress and assignee of a ticket + description: '' + operationId: getTicketDetails + tags: *ref_2 + parameters: + - in: query + name: ticketId + description: Ticket id of the feedback + schema: *ref_0 + required: true + - in: query + name: projectId + description: Entity ref of component + schema: *ref_0 + required: true + requestBody: *ref_3 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: Status of the ticket + assignee: + type: string + description: To whom the ticket is assigned + avatarUrls: + type: object + properties: {} + description: Object of avatars of assignee + message: + type: string + description: Fetched successfully + required: + - status + - assignee + - avatarUrls + - message + description: Ticket details fetched successfully + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Failed to fetch ticket details + description: Failed to fetch ticket details +components: + schemas: + Feedback Model: + type: object + properties: + feedbackId: + type: string + description: Id of feedback + summary: + type: string + description: Summary of feedback + projectId: + type: string + description: EntityRef of component + description: + type: string + description: Description of feedback + url: + type: string + description: Url from which feedback was submitted + userAgent: + type: string + description: User-Agent of origin + tag: + anyOf: + - $ref: '#/components/schemas/Tags' + description: Tags + ticketUrl: + type: string + description: Url to view ticket + createdBy: + type: string + description: EntityRef of user who created the feedback + createdAt: + type: string + description: ISO timestamp when feedback is created + format: date-time + updatedBy: + type: string + description: EntityRef of user who updated the feedback + updatedAt: + type: string + description: ISO timestamp when feedback is updated + format: date-time + feedbackType: + oneOf: + - $ref: '#/components/schemas/Feedback Type' + description: Type of feedback + required: + - url + - createdAt + - ticketUrl + - tag + - userAgent + - updatedAt + - feedbackType + - description + - projectId + - summary + description: Feedback Model + Tags: + type: string + description: All Tags + enum: + - Good + - Excellent + - Needs Improvement + - Slow Loading + - Navigation + - Not Responsive + - UI Issues + - Other + enumDesc: Accepted tags + pattern: '' + Feedback Type: + type: string + description: Type of feedback + enum: + - FEEDBACK + - BUG + Error: + type: object + properties: + error: + type: string + required: + - error diff --git a/plugins/feedback-backend/migrations/20231204093742_init_createTable.js b/plugins/feedback-backend/migrations/20231204093742_init_createTable.js new file mode 100644 index 0000000000..911bda5341 --- /dev/null +++ b/plugins/feedback-backend/migrations/20231204093742_init_createTable.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +exports.up = async function up(knex) { + return await knex.schema.createTable('feedback', table => { + table.string('feedbackId').notNullable(); + table.string('summary').notNullable(); + table.text('description'); + table.string('tag'); + table.string('projectId').notNullable(); + table.string('ticketUrl'); + table.enum('feedbackType', ['BUG', 'FEEDBACK']); + table.string('createdBy').notNullable(); + table.dateTime('createdAt').notNullable().defaultTo(knex.fn.now()); + table.string('updatedBy').notNullable(); + table.dateTime('updatedAt').notNullable().defaultTo(knex.fn.now()); + table.string('url').notNullable(); + table.string('userAgent').notNullable(); + table.index(['feedbackId', 'projectId'], 'feedbackId_projectId_index'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('feedback', table => { + table.dropIndex(['feedbackId', 'projectId'], 'feedbackId_projectId_index'); + }); + return await knex.schema.dropTable('feedback'); +}; diff --git a/plugins/feedback-backend/package.json b/plugins/feedback-backend/package.json new file mode 100644 index 0000000000..4a40814c44 --- /dev/null +++ b/plugins/feedback-backend/package.json @@ -0,0 +1,63 @@ +{ + "name": "@janus-idp/backstage-plugin-feedback-backend", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "tsc": "tsc" + }, + "dependencies": { + "@backstage/backend-common": "^0.19.8", + "@backstage/backend-plugin-api": "^0.6.8", + "@backstage/catalog-client": "^1.5.1", + "@backstage/catalog-model": "^1.4.3", + "@backstage/config": "^1.0.8", + "@types/express": "*", + "axios": "^1.6.4", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "knex": "2.4.2", + "node-fetch": "^2.6.7", + "nodemailer": "^6.9.8", + "short-uuid": "^4.2.2", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "0.23.0", + "@types/nodemailer": "^6.4.14", + "@types/supertest": "^2.0.12", + "msw": "^1.0.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist", + "config.d.ts", + "app-config.janus-idp.yaml", + "migrations/**/*.{js,d.ts}" + ], + "configSchema": "config.d.ts", + "repository": "github:janus-idp/backstage-plugins", + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://janus-idp.io/", + "bugs": "https://github.com/janus-idp/backstage-plugins/issues" +} diff --git a/plugins/feedback-backend/src/api/index.ts b/plugins/feedback-backend/src/api/index.ts new file mode 100644 index 0000000000..8a067b9f1f --- /dev/null +++ b/plugins/feedback-backend/src/api/index.ts @@ -0,0 +1,5 @@ +export { + createJiraTicket, + getJiraUsernameByEmail, + getTicketDetails, +} from './jiraApiService'; diff --git a/plugins/feedback-backend/src/api/jiraApiService.test.ts b/plugins/feedback-backend/src/api/jiraApiService.test.ts new file mode 100644 index 0000000000..7978a308a5 --- /dev/null +++ b/plugins/feedback-backend/src/api/jiraApiService.test.ts @@ -0,0 +1,69 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import { + mockConfig, + mockCreateJiraTicketResp, + mockFeedback, + mockJiraTicketDetailsResp, + mockJiraUsernameResp, +} from '../mocks'; +import { + createJiraTicket, + getJiraUsernameByEmail, + getTicketDetails, +} from './jiraApiService'; + +const handlers = [ + rest.post( + 'https://jira.host/rest/api/latest/issue', + async (req, res, ctx) => { + const reqData = await req.json(); + const key = reqData.fields.project.key; + return res(ctx.json(mockCreateJiraTicketResp(key))); + }, + ), + rest.get('https://jira.host/rest/api/latest/user/search', (_, res, ctx) => + res(ctx.json(mockJiraUsernameResp)), + ), + rest.get('https://jira.host/rest/api/latest/issue/ticket-id', (_, res, ctx) => + res(ctx.json(mockJiraTicketDetailsResp)), + ), +]; + +describe('JIRA issue', () => { + const mswMockServer = setupServer(); + handlers.forEach(handler => mswMockServer.use(handler)); + mswMockServer.listen({ onUnhandledRequest: 'warn' }); + const jiraHost = mockConfig.feedback.integrations.jira[0].host; + const jiraToken = mockConfig.feedback.integrations.jira[0].token; + + it('createJiraTicket', async () => { + const data = await createJiraTicket({ + host: jiraHost, + authToken: jiraToken, + projectKey: 'proj-key', + summary: mockFeedback.summary!, + description: 'Submitted from Test App', + tag: mockFeedback.tag!, + feedbackType: mockFeedback.feedbackType!, + reporter: 'John Doe', + }); + expect(data.key).toEqual('proj-key-01'); + }); + + it('getJiraUsernameByEmail', async () => { + const data = await getJiraUsernameByEmail( + jiraHost, + 'john.doe@example.com', + jiraToken, + ); + expect(data).toEqual('John Doe'); + }); + + it('getTicketDetails', async () => { + const data = await getTicketDetails(jiraHost, 'ticket-id', jiraToken); + expect(data?.status).toEqual('Backlog'); + expect(data?.assignee).toEqual('John Doe'); + }); +}); diff --git a/plugins/feedback-backend/src/api/jiraApiService.ts b/plugins/feedback-backend/src/api/jiraApiService.ts new file mode 100644 index 0000000000..e84941fcc5 --- /dev/null +++ b/plugins/feedback-backend/src/api/jiraApiService.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; + +export const createJiraTicket = async (options: { + host: string; + authToken: string; + projectKey: string; + summary: string; + description: string; + tag: string; + feedbackType: string; + reporter?: string; +}): Promise => { + const { + host, + authToken, + projectKey, + summary, + description, + tag, + feedbackType, + reporter, + } = options; + const requestBody = { + fields: { + ...(reporter && { + reporter: { + name: reporter, + }, + }), + project: { + key: projectKey, + }, + summary: summary, + description: description, + labels: ['reported-by-backstage', tag.toLowerCase().split(' ').join('-')], + issuetype: { + name: feedbackType === 'BUG' ? 'Bug' : 'Task', + }, + }, + }; + const resp = await axios.post(`${host}/rest/api/latest/issue`, requestBody, { + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }); + return resp.data; +}; + +export const getJiraUsernameByEmail = async ( + host: string, + reporterEmail: string, + authToken: string, +): Promise => { + const resp = await axios.get( + `${host}/rest/api/latest/user/search?username=${reporterEmail}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + const data = resp.data; + if (data.length === 0) return undefined; + return data[0].name; +}; + +export const getTicketDetails = async ( + host: string, + ticketId: string, + authToken: string, +): Promise< + { status: string; assignee: string; avatarUrls: {} } | undefined +> => { + const resp = await axios.get(`${host}/rest/api/latest/issue/${ticketId}`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + return { + status: resp.data.fields.status.name, + assignee: resp.data.fields.assignee + ? resp.data.fields.assignee.displayName + : null, + avatarUrls: resp.data.fields.assignee + ? resp.data.fields.assignee.avatarUrls + : null, + }; +}; diff --git a/plugins/feedback-backend/src/database/feedbackStore.ts b/plugins/feedback-backend/src/database/feedbackStore.ts new file mode 100644 index 0000000000..a5db9d4d99 --- /dev/null +++ b/plugins/feedback-backend/src/database/feedbackStore.ts @@ -0,0 +1,232 @@ +import { + PluginDatabaseManager, + resolvePackagePath, +} from '@backstage/backend-common'; + +import { Knex } from 'knex'; +import short from 'short-uuid'; +import { Logger } from 'winston'; + +import { FeedbackModel } from '../model/feedback.model'; + +export interface FeedbackStore { + getFeedbackByUuid(uuid: string): Promise; + + storeFeedbackGetUuid( + data: FeedbackModel, + ): Promise<{ feedbackId: string; projectId: string } | 0>; + + getAllFeedbacks( + projectId: string, + page: number, + pageSize: number, + searchKey: string, + ): Promise<{ data: FeedbackModel[]; count: number }>; + + checkFeedbackId(feedbackId: string): Promise; + updateFeedback(data: FeedbackModel): Promise; + deleteFeedbackById(id: string): Promise; +} + +const migrationsDir = resolvePackagePath( + '@janus-idp/backstage-plugin-feedback-backend', // Package name + 'migrations', // Migrations directory +); + +export class DatabaseFeedbackStore implements FeedbackStore { + private constructor( + private readonly db: Knex, + private readonly logger: Logger, + ) {} + + static async create({ + database, + skipMigrations, + logger, + }: { + database: PluginDatabaseManager; + skipMigrations: boolean; + logger: Logger; + }): Promise { + const client = await database.getClient(); + + if (!database.migrations?.skip && !skipMigrations) { + await client.migrate.latest({ + directory: migrationsDir, + }); + } + return new DatabaseFeedbackStore(client, logger); + } + + async getFeedbackByUuid(feedbackId: string): Promise { + const result: FeedbackModel = await this.db('feedback') + .select('*') + .where({ feedbackId: feedbackId }) + .first(); + return result; + } + + async getAllFeedbacks( + projectId: string, + offset: number, + limit: number, + searchKey: string, + ): Promise<{ data: FeedbackModel[]; count: number }> { + const operator = this.db.client.config.client === 'pg' ? 'ilike' : 'like'; + const countKey = + this.db.client.config.client === 'pg' ? 'count' : 'count(`feedbackId`)'; + + const result: FeedbackModel[] = []; + + if (projectId !== 'all') { + const model = + searchKey.length > 0 + ? this.db('feedback') + .where('projectId', projectId) + .andWhere(builder => { + builder.orWhere('summary', operator, `%${searchKey}%`); + builder.orWhere('ticketUrl', operator, `%${searchKey}%`); + builder.orWhere('tag', operator, `%${searchKey}%`); + builder.orWhere('feedbackType', operator, `%${searchKey}%`); + builder.orWhere('projectId', operator, `%${searchKey}%`); + }) + : this.db('feedback').where('projectId', projectId); + try { + const tempFeedbacksArr = await model + .clone() + .count('feedbackId') + .groupBy('projectId'); + + const totalFeedbacks = tempFeedbacksArr[0]?.[countKey] ?? 0; + await model + .clone() + .orderBy('updatedAt', 'desc') + .offset(offset) + .limit(limit) + .then(res => { + res.forEach(data => result.push(data)); + }); + return { + data: result, + count: parseInt(totalFeedbacks as string, 10), + }; + } catch (error: any) { + this.logger.error(error.message); + } + return { data: result, count: 0 }; + } + try { + const model = + searchKey.length > 0 + ? this.db('feedback').where(builder => { + builder.orWhere('summary', operator, `%${searchKey}%`); + builder.orWhere('ticketUrl', operator, `%${searchKey}%`); + builder.orWhere('projectId', operator, `%${searchKey}%`); + builder.orWhere('tag', operator, `%${searchKey}%`); + builder.orWhere('feedbackType', operator, `%${searchKey}%`); + }) + : this.db('feedback'); + + const totalFeedbacks = + (await model.clone().count('feedbackId'))[0]?.[countKey] ?? 0; + await model + .clone() + .orderBy('updatedAt', 'desc') + .limit(limit) + .offset(offset) + .then(res => { + res.forEach(data => result.push(data)); + }); + return { + data: result, + count: parseInt(totalFeedbacks as string, 10), + }; + } catch (error: any) { + this.logger.error(error.message); + } + return { + data: result, + count: 0, + }; + } + + async storeFeedbackGetUuid( + data: FeedbackModel, + ): Promise<{ projectId: string; feedbackId: string } | 0> { + try { + const id = short().generate(); + if (await this.checkFeedbackId(id)) + return await this.storeFeedbackGetUuid(data); + await this.db('feedback').insert({ + feedbackId: id, + summary: data.summary, + projectId: data.projectId, + description: data.description, + tag: data.tag, + ticketUrl: data.ticketUrl, + feedbackType: data.feedbackType, + createdAt: data.createdAt, + createdBy: data.createdBy, + updatedAt: data.updatedAt, + updatedBy: data.updatedBy, + url: data.url, + userAgent: data.userAgent, + }); + + return { + feedbackId: id, + projectId: data.projectId as string, + }; + } catch (error: any) { + this.logger.error(error.message); + return 0; + } + } + + async checkFeedbackId(feedbackId: string): Promise { + const result: string = await this.db('feedback') + .select('feedbackId') + .where({ feedbackId: feedbackId }) + .first(); + + if (result === undefined) { + return false; + } + return true; + } + + async updateFeedback( + data: FeedbackModel, + ): Promise { + const model = this.db('feedback').where('feedbackId', data.feedbackId); + + if (data.projectId) model.update('projectId', data.projectId); + if (data.summary) model.update('summary', data.summary); + if (data.description) model.update('description', data.description); + if (data.tag) model.update('tag', data.tag); + if (data.ticketUrl) model.update('ticketUrl', data.ticketUrl); + if (data.feedbackType) model.update('feedbackType', data.feedbackType); + if (data.createdAt) model.update('createdAt', data.createdAt); + if (data.createdBy) model.update('createdBy', data.createdBy); + if (data.updatedAt) model.update('updatedAt', data.updatedAt); + if (data.updatedBy) model.update('updatedBy', data.updatedBy); + if (data.userAgent) model.update('userAgent', data.userAgent); + if (data.url) model.update('url', data.url); + + try { + await model.clone(); + const result: FeedbackModel = await this.db('feedback') + .select('*') + .where({ feedbackId: data.feedbackId }) + .first(); + return result; + } catch (error: any) { + this.logger.error(`Failed to update feedback: ${error.message}`); + return undefined; + } + } + + async deleteFeedbackById(id: string): Promise { + return await this.db('feedback').where('feedbackId', id).del(); + } +} diff --git a/plugins/feedback-backend/src/index.ts b/plugins/feedback-backend/src/index.ts new file mode 100644 index 0000000000..5d5bfdd3f8 --- /dev/null +++ b/plugins/feedback-backend/src/index.ts @@ -0,0 +1,2 @@ +export { createRouter } from './service/router'; +export { feedbackPlugin } from './plugin'; diff --git a/plugins/feedback-backend/src/mocks/config.ts b/plugins/feedback-backend/src/mocks/config.ts new file mode 100644 index 0000000000..5ecd09fbf7 --- /dev/null +++ b/plugins/feedback-backend/src/mocks/config.ts @@ -0,0 +1,30 @@ +export const mockConfig = { + app: { + title: 'Backstage Test App', + }, + backend: { + baseUrl: 'http://localhost:7007', + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + feedback: { + integrations: { + email: { + host: 'smtp-host', + port: 587, + auth: {}, + secure: false, + from: '"Example" ', + caCert: process.env.NODE_EXTRA_CA_CERTS, + }, + jira: [ + { + host: 'https://jira.host', + token: '###', + }, + ], + }, + }, +}; diff --git a/plugins/feedback-backend/src/mocks/entity.ts b/plugins/feedback-backend/src/mocks/entity.ts new file mode 100644 index 0000000000..282b224ad2 --- /dev/null +++ b/plugins/feedback-backend/src/mocks/entity.ts @@ -0,0 +1,20 @@ +import { Entity } from '@backstage/catalog-model'; + +export const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'example-website', + title: 'Example App', + namespace: 'default', + annotations: { + 'feedback/type': 'MAIL', + 'feedback/email-to': 'john.doe@example.com', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }, +}; diff --git a/plugins/feedback-backend/src/mocks/feedback.ts b/plugins/feedback-backend/src/mocks/feedback.ts new file mode 100644 index 0000000000..9494b45912 --- /dev/null +++ b/plugins/feedback-backend/src/mocks/feedback.ts @@ -0,0 +1,15 @@ +import { FeedbackCategory, FeedbackModel } from '../model/feedback.model'; + +export const mockFeedback: FeedbackModel = { + feedbackId: 'bubuPsc93VRYAwByZe8ZQ9', + summary: 'Unit Test Issue', + description: 'This is mock description', + tag: 'UI Issues', + projectId: 'component:default/example-website', + ticketUrl: 'https://demo-ticket-url/ticket-id', + feedbackType: FeedbackCategory.BUG, + createdBy: 'user:default/guest', + url: 'http://localhost:3000/catalog/default/component/example-website/feedback', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0', +}; diff --git a/plugins/feedback-backend/src/mocks/index.ts b/plugins/feedback-backend/src/mocks/index.ts new file mode 100644 index 0000000000..c1dd9a729c --- /dev/null +++ b/plugins/feedback-backend/src/mocks/index.ts @@ -0,0 +1,5 @@ +export * from './feedback'; +export * from './entity'; +export * from './user'; +export * from './config'; +export * from './jira'; diff --git a/plugins/feedback-backend/src/mocks/jira.ts b/plugins/feedback-backend/src/mocks/jira.ts new file mode 100644 index 0000000000..da9c7304a1 --- /dev/null +++ b/plugins/feedback-backend/src/mocks/jira.ts @@ -0,0 +1,19 @@ +export const mockJiraTicketDetailsResp = { + fields: { + status: { + name: 'Backlog', + }, + assignee: { + displayName: 'John Doe', + avatarUrls: { + '10x10': [], + }, + }, + }, +}; + +export const mockJiraUsernameResp = [{ name: 'John Doe' }]; + +export const mockCreateJiraTicketResp = (key: any) => { + return { id: '3490987634', key: `${key}-01` }; +}; diff --git a/plugins/feedback-backend/src/mocks/user.ts b/plugins/feedback-backend/src/mocks/user.ts new file mode 100644 index 0000000000..689d8ef30e --- /dev/null +++ b/plugins/feedback-backend/src/mocks/user.ts @@ -0,0 +1,18 @@ +import { UserEntity } from '@backstage/catalog-model'; + +export const mockUser: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + namespace: 'default', + name: 'John Doe', + }, + relations: [], + spec: { + memberOf: ['guests'], + profile: { + displayName: 'John Doe', + email: 'john.doe@example.com', + }, + }, +}; diff --git a/plugins/feedback-backend/src/model/feedback.model.ts b/plugins/feedback-backend/src/model/feedback.model.ts new file mode 100644 index 0000000000..e1d1d41475 --- /dev/null +++ b/plugins/feedback-backend/src/model/feedback.model.ts @@ -0,0 +1,20 @@ +export enum FeedbackCategory { + BUG = 'BUG', + FEEDBACK = 'FEEDBACK', +} + +export type FeedbackModel = { + feedbackId?: string; + summary?: string; + projectId?: string; + description?: string; + url?: string; + userAgent?: string; + tag?: string; + ticketUrl?: string; + feedbackType?: FeedbackCategory; + createdBy?: string; + updatedBy?: string; + createdAt?: string; + updatedAt?: string; +}; diff --git a/plugins/feedback-backend/src/plugin.ts b/plugins/feedback-backend/src/plugin.ts new file mode 100644 index 0000000000..e36c37d250 --- /dev/null +++ b/plugins/feedback-backend/src/plugin.ts @@ -0,0 +1,30 @@ +import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; + +import { createRouter } from './service/router'; + +export const feedbackPlugin = createBackendPlugin({ + pluginId: 'feedback', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + httpRouter: coreServices.httpRouter, + config: coreServices.rootConfig, + discovery: coreServices.discovery, + }, + async init({ logger, httpRouter, config, discovery }) { + httpRouter.use( + await createRouter({ + logger: loggerToWinstonLogger(logger), + config: config, + discovery: discovery, + }), + ); + }, + }); + }, +}); diff --git a/plugins/feedback-backend/src/run.ts b/plugins/feedback-backend/src/run.ts new file mode 100644 index 0000000000..594839f2e1 --- /dev/null +++ b/plugins/feedback-backend/src/run.ts @@ -0,0 +1,19 @@ +import { getRootLogger } from '@backstage/backend-common'; + +import yn from 'yn'; + +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/feedback-backend/src/service/emails.ts b/plugins/feedback-backend/src/service/emails.ts new file mode 100644 index 0000000000..cc89ad6ab8 --- /dev/null +++ b/plugins/feedback-backend/src/service/emails.ts @@ -0,0 +1,64 @@ +import { Config } from '@backstage/config'; + +import { createTransport, Transporter } from 'nodemailer'; +import { Logger } from 'winston'; + +import { readFileSync } from 'fs'; + +export class NodeMailer { + private readonly transportConfig: Transporter; + private readonly from: string; + + constructor( + config: Config, + private logger: Logger, + ) { + const useSecure: boolean = config.getBoolean( + 'feedback.integrations.email.secure', + ); + const caCertPath = config.getOptionalString( + 'feedback.integrations.email.caCert', + ); + const customCACert = caCertPath ? readFileSync(caCertPath) : undefined; + + this.from = config.getString('feedback.integrations.email.from'); + this.transportConfig = createTransport({ + host: config.getString('feedback.integrations.email.host'), + port: config.getOptionalNumber('feedback.integrations.email.port') ?? 587, + auth: { + user: config.getOptionalString('feedback.integrations.email.auth.user'), + pass: config.getOptionalString( + 'feedback.integrations.email.auth.password', + ), + }, + secure: useSecure, + tls: { + ca: customCACert, + }, + }); + } + + async sendMail(options: { + to: string; + replyTo: string; + subject: string; + body: string; + }): Promise<{}> { + try { + const { to, replyTo, subject, body } = options; + this.logger.info(`Sending mail to ${to}`); + const resp = await this.transportConfig.sendMail({ + to: to, + replyTo: replyTo, + cc: replyTo, + from: this.from, + subject: subject, + html: body, + }); + return resp; + } catch (error: any) { + this.logger.error(`Failed to send mail: ${error.message}`); + return {}; + } + } +} diff --git a/plugins/feedback-backend/src/service/router.test.ts b/plugins/feedback-backend/src/service/router.test.ts new file mode 100644 index 0000000000..5b3b8d7b55 --- /dev/null +++ b/plugins/feedback-backend/src/service/router.test.ts @@ -0,0 +1,186 @@ +import { HostDiscovery } from '@backstage/backend-common'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { Config, ConfigReader } from '@backstage/config'; + +import express from 'express'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import request from 'supertest'; +import { createLogger, Logger, transports } from 'winston'; + +import { + mockConfig, + mockEntity, + mockFeedback, + mockJiraTicketDetailsResp, + mockUser, +} from '../mocks'; +import { createRouter } from './router'; + +const handlers = [ + rest.get( + 'http://localhost:7007/api/catalog/entities/by-name/component/default/example-website', + (_, res, ctx) => res(ctx.json(mockEntity)), + ), + rest.get( + 'http://localhost:7007/api/catalog/entities/by-name/user/default/guest', + (_, res, ctx) => res(ctx.json(mockUser)), + ), + rest.get('https://jira.host/rest/api/latest/issue/ticket-id', (_, res, ctx) => + res(ctx.json(mockJiraTicketDetailsResp)), + ), +]; + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn().mockReturnValue({ + sendMail: jest.fn(), + }), +})); + +jest.mock('../database/feedbackStore', () => ({ + DatabaseFeedbackStore: { + create: jest.fn().mockImplementation(() => { + return { + getFeedbackByUuid: () => Promise.resolve(mockFeedback), + checkFeedbackId: (feedbackId: string) => + feedbackId === mockFeedback.feedbackId, + getAllFeedbacks: () => + Promise.resolve({ + data: [mockFeedback], + count: 1, + }), + storeFeedbackGetUuid: () => + Promise.resolve({ + feedbackId: mockFeedback.feedbackId, + projectId: mockFeedback.projectId, + }), + updateFeedback: (data: any) => Promise.resolve(data), + deleteFeedbackById: () => Promise.resolve(), + }; + }), + }, +})); + +describe('Router', () => { + const mswMockServer = setupServer(); + handlers.forEach(handler => mswMockServer.use(handler)); + mswMockServer.listen({ onUnhandledRequest: 'bypass' }); + const config: Config = new ConfigReader(mockConfig); + const discovery: DiscoveryService = HostDiscovery.fromConfig(config); + const logger: Logger = createLogger({ + transports: [new transports.Console({ silent: true })], + }); + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: logger, + config: config, + discovery: discovery, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET', () => { + it('/\t\t\tshould return all feedbacks', async () => { + const response = await request(app).get('/'); + expect(response.statusCode).toEqual(200); + expect(response.body.data[0]).toEqual(mockFeedback); + expect(response.body.count).toEqual(1); + }); + it('/:feedbackId\t\tshould return single feedback', async () => { + const response = await request(app).get(`/${mockFeedback.feedbackId}`); + expect(response.statusCode).toEqual(200); + expect(response.body.data).toEqual(mockFeedback); + }); + it('/:feedbackId\t\tshould give error', async () => { + const response = await request(app).get(`/1234567890`); + expect(response.statusCode).toEqual(404); + expect(response.body.error).toEqual( + 'No feedback found for id 1234567890', + ); + }); + it('/:feedbackId/ticket\tshould give error', async () => { + const response = await request(app) + .get(`/${mockFeedback.feedbackId}/ticket`) + .query({ + ticketId: mockFeedback.ticketUrl?.split('/').pop(), + projectId: mockFeedback.projectId, + }); + expect(response.statusCode).toEqual(404); + expect(response.body.error).toEqual( + `Unable to fetch jira ticket ${mockFeedback.ticketUrl + ?.split('/') + .pop()}`, + ); + }); + it('/:feedbackId/ticket\tshould return single feedback', async () => { + mockEntity.metadata.annotations!['feedback/type'] = 'jira'; + const response = await request(app) + .get(`/${mockFeedback.feedbackId}/ticket`) + .query({ + ticketId: mockFeedback.ticketUrl?.split('/').pop(), + projectId: mockFeedback.projectId, + }); + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('fetched successfully'); + expect(response.body.data.status).toEqual('Backlog'); + expect(response.body.data.assignee).toEqual('John Doe'); + mockEntity.metadata.annotations!['feedback/type'] = 'mail'; + }); + }); + + describe('POST', () => { + it('/\tshould give error', async () => { + const response = await request(app) + .post('/') + .send({ description: mockFeedback.description }); + expect(response.body.error).toEqual('Summary field empty'); + expect(response.statusCode).toEqual(500); + }); + it('/\tshould create feedback', async () => { + const response = await request(app).post('/').send(mockFeedback); + expect(response.body.message).toEqual('Issue created successfully'); + expect(response.body.data.feedbackId).toEqual(mockFeedback.feedbackId); + expect(response.statusCode).toEqual(201); + }); + }); + + describe('PATCH', () => { + it('/:feedbackId\tshould return updated feedback', async () => { + const response = await request(app) + .patch(`/${mockFeedback.feedbackId}`) + .send({ ...mockFeedback, summary: 'This is updated summmary' }); + expect(response.body.summary).not.toEqual(mockFeedback.summary); + expect(response.statusCode).toEqual(200); + }); + it('/:feedbackId\tshould give error', async () => { + const response = await request(app) + .patch(`/1234567890`) + .send({ ...mockFeedback, summary: 'This is updated summmary' }); + expect(response.body.error).toEqual( + 'No feedback found for id 1234567890', + ); + expect(response.statusCode).toEqual(404); + }); + }); + + describe('DELETE', () => { + it('/:feedbackId\tshould delete feedback', async () => { + const response = await request(app).delete(`/${mockFeedback.feedbackId}`); + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('Deleted successfully'); + }); + it('/:feedbackId\tshould give error', async () => { + const response = await request(app).delete(`/1234567890`); + expect(response.statusCode).toEqual(404); + expect(response.body.error).toEqual( + 'No feedback found for id 1234567890', + ); + }); + }); +}); diff --git a/plugins/feedback-backend/src/service/router.ts b/plugins/feedback-backend/src/service/router.ts new file mode 100644 index 0000000000..46d9166ee3 --- /dev/null +++ b/plugins/feedback-backend/src/service/router.ts @@ -0,0 +1,315 @@ +import { + DatabaseManager, + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; + +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; + +import { + createJiraTicket, + getJiraUsernameByEmail, + getTicketDetails, +} from '../api'; +import { DatabaseFeedbackStore } from '../database/feedbackStore'; +import { FeedbackModel } from '../model/feedback.model'; +import { NodeMailer } from './emails'; + +export interface RouterOptions { + logger: Logger; + config: Config; + discovery: PluginEndpointDiscovery; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, config, discovery } = options; + + const router = Router(); + const feedbackDB = await DatabaseFeedbackStore.create({ + database: DatabaseManager.fromConfig(config).forPlugin('feedback'), + skipMigrations: false, + logger: logger, + }); + + const mailer = new NodeMailer(config, logger); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + + router.use(express.json()); + + router.post('/', (req, res) => { + (async () => { + const reqData: FeedbackModel = req.body; + if (!reqData.summary || reqData.summary?.trim().length < 1) { + return res.status(500).json({ error: 'Summary field empty' }); + } + reqData.createdAt = new Date().toISOString(); + reqData.updatedBy = reqData.createdBy; + reqData.updatedAt = reqData.createdAt; + + const entityRef: Entity | undefined = await catalogClient.getEntityByRef( + reqData.projectId!, + ); + const entityRoute = `${req.headers.origin}/catalog/${entityRef?.metadata.namespace}/${entityRef?.kind}/${entityRef?.metadata.name}/feedback`; + + const feedbackType = + reqData.feedbackType === 'FEEDBACK' ? 'Feedback' : 'Issue'; + + if (reqData.summary?.length > 255) { + reqData.description = reqData.summary + ?.concat('\n\n') + .concat(reqData.description ?? ''); + reqData.summary = `${feedbackType} reported by ${reqData.createdBy?.split( + '/', + )[1]} for ${entityRef?.metadata.title ?? entityRef?.metadata.name}`; + } + + const respObj = await feedbackDB.storeFeedbackGetUuid(reqData); + if (respObj === 0) { + return res.status(500).json({ + error: `Failed to create ${feedbackType}`, + }); + } + + reqData.feedbackId = respObj.feedbackId; + res.status(201).json({ + message: `${feedbackType} created successfully`, + data: respObj, + }); + + if (entityRef?.metadata.annotations) { + const annotations = entityRef.metadata.annotations; + const type = annotations['feedback/type']; + const replyTo = annotations['feedback/email-to']; + const reporterEmail = ( + (await catalogClient.getEntityByRef(reqData.createdBy!))?.spec + ?.profile as { email: string } + ).email; + const appTitle = config.getString('app.title'); + + if ( + type.toUpperCase() === 'JIRA' && + !reqData.tag?.match(/(Excellent|Good)/g) + ) { + let host = annotations['feedback/host']; + // if host is undefined then + // use the first host from config + const serviceConfig = + config + .getConfigArray('feedback.integrations.jira') + .find(hostConfig => host === hostConfig.getString('host')) ?? + config.getConfigArray('feedback.integrations.jira')[0]; + host = serviceConfig.getString('host'); + const authToken = serviceConfig.getString('token'); + + const projectKey = entityRef.metadata.annotations['jira/project-key']; + const jiraUsername = await getJiraUsernameByEmail( + host, + reporterEmail, + authToken, + ); + // if jira id is not there for reporter, add reporter email in description + const jiraDescription = reqData.description!.concat( + `\n\n${ + jiraUsername === undefined + ? `Reported by: ${reporterEmail}` + : '\r' + }\n*Submitted from ${appTitle}*\n[${feedbackType} link|${entityRoute}?id=${ + reqData.feedbackId + }]`, + ); + + const resp = await createJiraTicket({ + host: host, + authToken: authToken, + projectKey: projectKey, + summary: reqData.summary, + description: jiraDescription, + tag: reqData.tag!.toLowerCase().split(' ').join('-'), + feedbackType: reqData.feedbackType!, + reporter: jiraUsername, + }); + reqData.ticketUrl = `${host}/browse/${resp.key}`; + await feedbackDB.updateFeedback(reqData); + } + + if (type.toUpperCase() === 'MAIL' || replyTo) { + mailer.sendMail({ + to: reporterEmail, + replyTo: replyTo, + subject: `${ + reqData.tag + } - ${feedbackType} reported for ${reqData.projectId?.split( + '/', + )[1]}`, + body: ` +
+ Hi ${reqData.createdBy?.split('/')[1]}, +
+
+ We have received your feedback for + + ${reqData.projectId?.split('/')[1]} + , + and here are the details: +
+
+ Summary: ${reqData.summary} +
+
+ ${ + reqData.description?.length! > 0 + ? `Description: ${reqData.description} +
+
` + : '\r' + } + Submitted from: ${reqData.url} +
+ Submitted at: ${new Date(reqData.createdAt).toUTCString()} +
+
+ + View on ${appTitle} + +
`, + }); + } + } + return 1; + })(); + }); + + router.get('/', (req, res) => { + (async () => { + const projectId = req.query.projectId + ? (req.query.projectId as string) + : 'all'; + const offset = req.query.offset + ? parseInt(req.query.offset as string, 10) + : 0; + const limit = req.query.limit + ? parseInt(req.query.limit as string, 10) + : 10; + const searchKey = req.query.query?.toString() ?? ''; + + const feedbackData = await feedbackDB.getAllFeedbacks( + projectId, + offset, + limit, + searchKey, + ); + const page = offset / limit + 1; + return res + .status(200) + .json({ ...feedbackData, currentPage: page, pageSize: limit }); + })(); + }); + + router.get('/:id', (req, res) => { + (async () => { + const feedbackId = req.params.id; + + if (await feedbackDB.checkFeedbackId(feedbackId)) { + const feedback: FeedbackModel = + await feedbackDB.getFeedbackByUuid(feedbackId); + return res.status(200).json({ + data: feedback, + message: 'Feedback fetched successfully', + }); + } + return res + .status(404) + .json({ error: `No feedback found for id ${feedbackId}` }); + })(); + }); + + router.get('/:id/ticket', (req, res) => { + (async () => { + const ticketId = req.query.ticketId + ? (req.query.ticketId as string) + : null; + const projectId = req.query.projectId + ? (req.query.projectId as string) + : null; + + if (ticketId && projectId) { + const entityRef: Entity | undefined = + await catalogClient.getEntityByRef(projectId); + const feedbackType = entityRef?.metadata.annotations?.['feedback/type']; + if (feedbackType?.toLowerCase() === 'jira') { + let host = entityRef?.metadata.annotations?.['feedback/host']; + + // if host is undefined then + // use the first host from config + const serviceConfig = + config + .getConfigArray('feedback.integrations.jira') + .find(hostConfig => host === hostConfig.getString('host')) ?? + config.getConfigArray('feedback.integrations.jira')[0]; + host = serviceConfig.getString('host'); + const authToken = serviceConfig.getString('token'); + + const resp = await getTicketDetails(host, ticketId, authToken); + return res.status(200).json({ + data: { ...resp }, + message: 'fetched successfully', + }); + } + } + + return res + .status(404) + .json({ error: `Unable to fetch jira ticket ${ticketId}` }); + })(); + }); + + // patch and delete apis + router.patch('/:id', (req, res) => { + (async () => { + const feedbackId = req.params.id; + const data: FeedbackModel = req.body; + + if (await feedbackDB.checkFeedbackId(feedbackId)) { + data.feedbackId = feedbackId; + data.updatedAt = new Date().toISOString(); + const updatedData = await feedbackDB.updateFeedback(data); + if (updatedData) { + return res.status(200).json({ + data: updatedData, + message: 'Feedback updated successfully', + }); + } + return res.status(500).json({ error: 'Failed to edit the feedback' }); + } + + return res + .status(404) + .json({ error: `No feedback found for id ${feedbackId}` }); + })(); + }); + + router.delete('/:id', (req, res) => { + (async () => { + const feedbackId = req.params.id; + if (await feedbackDB.checkFeedbackId(feedbackId)) { + await feedbackDB.deleteFeedbackById(feedbackId); + return res.status(200).json({ message: 'Deleted successfully' }); + } + + logger.error(`No feedback found for id ${feedbackId}`); + return res + .status(404) + .json({ error: `No feedback found for id ${feedbackId}` }); + })(); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/feedback-backend/src/service/standaloneServer.ts b/plugins/feedback-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..5b16f09eeb --- /dev/null +++ b/plugins/feedback-backend/src/service/standaloneServer.ts @@ -0,0 +1,45 @@ +import { createServiceBuilder, HostDiscovery } from '@backstage/backend-common'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { Config, ConfigReader } from '@backstage/config'; + +import { createLogger, Logger, transports } from 'winston'; + +import { Server } from 'http'; + +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const config: Config = new ConfigReader({}); + const discovery: DiscoveryService = HostDiscovery.fromConfig(config); + const logger: Logger = createLogger({ + transports: [new transports.Console({ silent: true })], + }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger: logger, + config: config, + discovery: discovery, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/feedback', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/feedback-backend/src/setupTests.ts b/plugins/feedback-backend/src/setupTests.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/plugins/feedback-backend/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/plugins/feedback-backend/tsconfig.json b/plugins/feedback-backend/tsconfig.json new file mode 100644 index 0000000000..df5f234d11 --- /dev/null +++ b/plugins/feedback-backend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src", "dev", "migrations"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "../../dist-types/plugins/feedback-backend", + "rootDir": "." + } +} diff --git a/plugins/feedback-backend/turbo.json b/plugins/feedback-backend/turbo.json new file mode 100644 index 0000000000..e88834b1eb --- /dev/null +++ b/plugins/feedback-backend/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "pipeline": { + "tsc": { + "outputs": ["../../dist-types/plugins/feedback-backend/**"], + "dependsOn": ["^tsc"] + } + } +} diff --git a/plugins/feedback/.eslintrc.js b/plugins/feedback/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/feedback/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/feedback/OWNERS b/plugins/feedback/OWNERS new file mode 100644 index 0000000000..ac83ec4203 --- /dev/null +++ b/plugins/feedback/OWNERS @@ -0,0 +1,8 @@ +approvers: + - yashoswalyo + - riginoommen + - deshmukhmayur +reviewers: + - yashoswalyo + - riginoommen + - deshmukhmayur diff --git a/plugins/feedback/README.md b/plugins/feedback/README.md new file mode 100644 index 0000000000..483ab6efee --- /dev/null +++ b/plugins/feedback/README.md @@ -0,0 +1,174 @@ +# Feedback Plugin + +## Introduction + +Feedback plugin is a valuable addition to backstage which allows project managers to get feedbacks for entites in Backstage Catalog. + +It is dedicated to simplifying the process of gathering and managing user feedback for service catalog entities. This plugin seamlessly integrates with the [feedback-backend-plugin](../feedback-backend) and extends its capabilities by allowing users to create Jira tickets associated with their feedback. + +
+Screenshots + +| Global Page | Entity Page | +| -------------------------------------------- | -------------------------------------------- | +| ![globalPage](./docs/images/global-page.png) | ![entityPage](./docs/images/entity-page.png) | + +| Feedback Details Modal | Create Feedback Modal | +| -------------------------------------------------- | --------------------------------------------- | +| ![feedbacDetails](./docs/images/details-modal.png) | ![entityPage](./docs/images/create-modal.png) | + +| Opc Feedback Component | | | | +| -------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------- | +| ![initialDialog](./docs/images/initial-dialog.png) | ![issueDialog](./docs/images/issue-dialog.png) | ![feedbackDialog](./docs/images/feedback-dialog.png) | ![finalDialog](./docs/images/final-dialog.png) | + +
+ +### Key Features + +- List all the feedbacks and bugs for the componnets on global page. +- List all the feedbacks and bugs for each component on entity page. +- Create Bugs, Feedbacks directly on JIRA and also sends a mail to reporter. +- Unique feedback links for each feedback. +- Works with all knex supported databases. + +### Requirements + +- Make sure that [feedback-backend-plugin](../feedback-backend) is configured prior to this. + +### Plugin Setup + +1. Install the plugin in your environment + + ```bash + yarn workspace app add @janus-idp/backstage-plugin-feedback + ``` + +2. Add configuration to app-config.yml + + ```yaml + feedback: + # A ref to base entity under which global feedbacks gets stored + # in format: kind:namespace/name + baseEntityRef: 'component:default/example-website' + + # Limit the number of characters for summary field + # should be between 1-255 + summaryLimit: 240 + ``` + +3. Add `GlobalFeedbackPage`, `OpcFeedbackComponent` component to the `src/App.tsx`. + + ```jsx + import { + feedbackPlugin, + GlobalFeedbackPage, + OpcFeedbackComponent, + } from '@janus-idp/backstage-plugin-feedback'; + + // ... + const app = createApp({ + apis, + bindRoutes({ bind }) { + // ... + // Bind techdocs root route to feedback plugin externalRoute.viewDocs to add "View Docs" link in opc-feedback component + bind(feedbackPlugin.externalRoutes, { + viewDocs: techdocsPlugin.routes.root, + }); + }, + featureFlags: [ + // ... + ], + }); + const routes = ( + + // Insert this line to add feedback route + } /> + + ); + + export default app.createRoot( + <> + // ... + + // ... + + + , + ); + ``` + +4. Then add the feedback route to sidebar to easily access `/feedback`, in `src/components/Root/Root.tsx`. + + ```ts + import TextsmsOutlined from '@material-ui/icons/TextsmsOutlined'; + //... + export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + // ... + }> + // ... + // Insert these lines in SidebarGroup + + + + // ... + // ... + + ) + ``` + +5. Add `EntityFeedbackPage` component to the **Component page, Api page** in `src/components/catalog/EntityPage.tsx`. + + ```ts + + + + ``` + +### Annotations + +To configure only mail: + +- Add these annotations to your `catalog-info.yaml` file. + +```yaml +metadata: + annotations: + # Set to MAIL, if you want to recevie mail + # on every feedback. + feedback/type: 'MAIL' + + # Type in your mail here, it will be kept in cc, + # while sending mail on feedback generation. + feedback/email-to: 'example@example.com' +``` + +To configure Jira + mail: + +- Add these annotations to your `catalog-info.yaml` file. + +```yaml +metadata: + annotations: + # Set to JIRA to create ticket when + # creating feedbacks. + feedback/type: 'JIRA' + + # Enter your jira project key, + jira/project-key: '' + + # (optional) Enter the url of you jira server. + # If not set then it will use first host from app-config + feedback/host: '' + + # (optional) Type in your mail here, + # it will be kept in cc, + # while sending mail on feedback generation. + feedback/email-to: 'example@example.com'; +``` + +### Credits + +- @1-Platform for [opc-feedback](https://github.com/1-platform/op-components) component. +- diff --git a/plugins/feedback/app-config.janus-idp.yaml b/plugins/feedback/app-config.janus-idp.yaml new file mode 100644 index 0000000000..bd7ca79376 --- /dev/null +++ b/plugins/feedback/app-config.janus-idp.yaml @@ -0,0 +1,8 @@ +feedback: + # A ref to base entity under which global feedbacks gets stored + # in format: 'kind:namespace/name', eg: 'component:default/example-website' + baseEntityRef: ${FEEDBACK_PLUGIN_BASE_ENTITY} + + # Limit the number of characters for summary field + # should be between 1-255 + summaryLimit: ${FEEDBACK_PLUGIN_SUMMARY_LIMIT} diff --git a/plugins/feedback/config.d.ts b/plugins/feedback/config.d.ts new file mode 100644 index 0000000000..ee2462ba38 --- /dev/null +++ b/plugins/feedback/config.d.ts @@ -0,0 +1,34 @@ +/** + * Configuration options for the application. + */ +export interface Config { + /** + * @visibility frontend + */ + feedback?: { + /** + * @visibility frontend + */ + summaryLimit?: number; + /** + * @visibility frontend + */ + baseEntityRef: string; + /** + * @visibility frontend + * */ + integrations: { + /** + * Configuration options for JIRA integration. + * It is an array, which can be used to set up multiple jira servers at the same time. + */ + jira?: Array<{ + /** + * The hostname or URL of the JIRA organization. + * @visibility frontend + */ + host: string; + }>; + }; + }; +} diff --git a/plugins/feedback/dev/index.tsx b/plugins/feedback/dev/index.tsx new file mode 100644 index 0000000000..29a15e8aba --- /dev/null +++ b/plugins/feedback/dev/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +import { mockEntity } from '../src/mocks'; +import { + EntityFeedbackPage, + feedbackPlugin, + GlobalFeedbackPage, + OpcFeedbackComponent, +} from '../src/plugin'; + +createDevApp() + .registerPlugin(feedbackPlugin) + .addPage({ + element: ( + <> + + + ), + title: 'Root Page', + path: '/feedback', + }) + .addPage({ + element: ( +
+ + + + +
+ ), + title: 'Entity Page', + path: '/catalog/default/component/example-website-for-feedback-plugin', + }) + .render(); diff --git a/plugins/feedback/docs/images/create-modal.png b/plugins/feedback/docs/images/create-modal.png new file mode 100644 index 0000000000..04620b1504 Binary files /dev/null and b/plugins/feedback/docs/images/create-modal.png differ diff --git a/plugins/feedback/docs/images/details-modal.png b/plugins/feedback/docs/images/details-modal.png new file mode 100644 index 0000000000..b1d7221d1b Binary files /dev/null and b/plugins/feedback/docs/images/details-modal.png differ diff --git a/plugins/feedback/docs/images/entity-page.png b/plugins/feedback/docs/images/entity-page.png new file mode 100644 index 0000000000..6805d7f7d6 Binary files /dev/null and b/plugins/feedback/docs/images/entity-page.png differ diff --git a/plugins/feedback/docs/images/feedback-dialog.png b/plugins/feedback/docs/images/feedback-dialog.png new file mode 100644 index 0000000000..b37c40d872 Binary files /dev/null and b/plugins/feedback/docs/images/feedback-dialog.png differ diff --git a/plugins/feedback/docs/images/final-dialog.png b/plugins/feedback/docs/images/final-dialog.png new file mode 100644 index 0000000000..a3aa20a78a Binary files /dev/null and b/plugins/feedback/docs/images/final-dialog.png differ diff --git a/plugins/feedback/docs/images/global-page.png b/plugins/feedback/docs/images/global-page.png new file mode 100644 index 0000000000..0a38032343 Binary files /dev/null and b/plugins/feedback/docs/images/global-page.png differ diff --git a/plugins/feedback/docs/images/initial-dialog.png b/plugins/feedback/docs/images/initial-dialog.png new file mode 100644 index 0000000000..ecd5f13a42 Binary files /dev/null and b/plugins/feedback/docs/images/initial-dialog.png differ diff --git a/plugins/feedback/docs/images/issue-dialog.png b/plugins/feedback/docs/images/issue-dialog.png new file mode 100644 index 0000000000..888c26f135 Binary files /dev/null and b/plugins/feedback/docs/images/issue-dialog.png differ diff --git a/plugins/feedback/package.json b/plugins/feedback/package.json new file mode 100644 index 0000000000..2fe2659fa9 --- /dev/null +++ b/plugins/feedback/package.json @@ -0,0 +1,66 @@ +{ + "name": "@janus-idp/backstage-plugin-feedback", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "tsc": "tsc" + }, + "dependencies": { + "@backstage/catalog-model": "^1.4.3", + "@backstage/config": "^1.1.1", + "@backstage/core-components": "^0.13.6", + "@backstage/core-plugin-api": "^1.7.0", + "@backstage/plugin-catalog-react": "^1.9.2", + "@backstage/theme": "^0.4.3", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "@one-platform/opc-feedback": "0.1.1-alpha", + "axios": "^1.6.4", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "0.23.0", + "@backstage/core-app-api": "^1.11.0", + "@backstage/dev-utils": "1.0.22", + "@backstage/test-utils": "1.4.4", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0" + }, + "files": [ + "app-config.janus-idp.yaml", + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts", + "repository": "github:janus-idp/backstage-plugins", + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://janus-idp.io/", + "bugs": "https://github.com/janus-idp/backstage-plugins/issues" +} diff --git a/plugins/feedback/src/api/index.ts b/plugins/feedback/src/api/index.ts new file mode 100644 index 0000000000..8926423dfe --- /dev/null +++ b/plugins/feedback/src/api/index.ts @@ -0,0 +1,108 @@ +import { + ConfigApi, + createApiRef, + DiscoveryApi, + IdentityApi, +} from '@backstage/core-plugin-api'; + +import { FeedbackType } from '../models/feedback.model'; + +export const feedbackApiRef = createApiRef({ + id: 'plugin.feedback.service', +}); + +type Options = { + discoveryApi: DiscoveryApi; + configApi: ConfigApi; + identityApi: IdentityApi; +}; + +type feedbackResp = { + data?: FeedbackType; + message?: string; + error?: string; +}; + +type feedbacksResp = { + data: FeedbackType[]; + count: number; + currentPage: number; + pageSize: number; +}; + +export class FeedbackAPI { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + } + + async getAllFeedbacks( + page: number, + pageSize: number, + projectId: string, + searchText: string, + ) { + const baseUrl = await this.discoveryApi.getBaseUrl('feedback'); + const offset = (page - 1) * pageSize; + try { + const resp = await fetch( + `${baseUrl}?query=${searchText}&offset=${offset}&limit=${pageSize}&projectId=${projectId}`, + ); + const respData: feedbacksResp = await resp.json(); + return respData; + } catch (error) { + return { data: [], count: 0, currentPage: page, pageSize: pageSize }; + } + } + + async getFeedbackById(feedbackId: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('feedback'); + const resp = await fetch(`${baseUrl}/${feedbackId}`); + const respData: feedbackResp = await resp.json(); + return respData; + } + + async createFeedback( + data: any, + ): Promise<{ data?: {}; message?: string; error?: string }> { + try { + const baseUrl = await this.discoveryApi.getBaseUrl('feedback'); + const resp = await fetch(`${baseUrl}`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + }); + const respData = await resp.json(); + return respData; + } catch (error: any) { + return { error: error.message }; + } + } + + async getTicketDetails( + feedbackId: string, + ticketUrl: string, + projectId: string, + ): Promise<{ status: string; assignee: string; avatarUrls: any }> { + const baseUrl = await this.discoveryApi.getBaseUrl('feedback'); + const ticketId = ticketUrl.split('/').at(-1); + const resp = await fetch( + `${baseUrl}/${feedbackId}/ticket?ticketId=${ticketId}&projectId=${projectId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const data = (await resp.json()).data; + return { + status: data.status, + assignee: data.assignee, + avatarUrls: data.avatarUrls, + }; + } +} diff --git a/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.test.tsx b/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.test.tsx new file mode 100644 index 0000000000..a271049b75 --- /dev/null +++ b/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { fireEvent } from '@testing-library/react'; + +import { FeedbackAPI, feedbackApiRef } from '../../api'; +import { mockEntity } from '../../mocks'; +import { CreateFeedbackModal } from './CreateFeedbackModal'; + +describe('Create Feedback Modal', () => { + const feedbackApi: Partial = { + createFeedback: jest.fn(), + }; + + const PROJECT_ID = 'component:default/example-website'; + const USER_ID = 'user:default/guest'; + + const handleModalClose = jest.fn().mockReturnValue(true); + + const render = async () => + await renderInTestApp( + + + , + ); + + it('should render', async () => { + const rendered = await render(); + expect(rendered).toBeDefined(); + }); + + it('should render the modal title', async () => { + const rendered = await render(); + expect( + rendered.getByText(`Feedback for ${PROJECT_ID.split('/').pop()}`), + ).toBeInTheDocument(); + }); + + test('BUG should be selected', async () => { + const rendered = await render(); + const tags = rendered.getAllByRole('radio') as any; + expect(tags[0].checked).toBeTruthy(); + expect(tags[1].checked).not.toBeTruthy(); + }); + + it('should render all tags for bug', async () => { + const rendered = await render(); + expect( + rendered.getByRole('heading', { + name: 'Select Bug: Slow Loading Not Responsive Navigation UI Issues Other', + }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Slow Loading' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Not Responsive' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Navigation' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'UI Issues' }), + ).toBeInTheDocument(); + expect(rendered.getByRole('button', { name: 'Other' })).toBeInTheDocument(); + }); + + it('should have correct labels', async () => { + const rendered = await render(); + + expect( + rendered.getByRole('textbox', { name: 'Summary' }), + ).toBeInTheDocument(); + + expect( + rendered.getByRole('textbox', { name: 'Description' }), + ).toBeInTheDocument(); + }); + + it('should render submit buttons', async () => { + const rendered = await render(); + expect( + rendered.getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Report Bug' }), + ).toBeInTheDocument(); + }); + + // Select type to feedback + it('should select type to feedback', async () => { + const rendered = await render(); + fireEvent.click(rendered.getByRole('radio', { name: 'Feedback' })); + + expect( + rendered.getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + }); + + it('should render the modal title for feedback', async () => { + const rendered = await render(); + fireEvent.click(rendered.getByRole('radio', { name: 'Feedback' })); + + expect( + rendered.getByText(`Feedback for ${PROJECT_ID.split('/').pop()}`), + ).toBeInTheDocument(); + }); + + it('should render all tags for feedback', async () => { + const rendered = await render(); + fireEvent.click(rendered.getByRole('radio', { name: 'Feedback' })); + + expect( + rendered.getByRole('heading', { + name: 'Select Feedback: Excellent Good Needs Improvement Other', + }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Excellent' }), + ).toBeInTheDocument(); + expect(rendered.getByRole('button', { name: 'Good' })).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Needs Improvement' }), + ).toBeInTheDocument(); + expect(rendered.getByRole('button', { name: 'Other' })).toBeInTheDocument(); + }); + + it('should render submit buttons for feedback', async () => { + const rendered = await render(); + fireEvent.click(rendered.getByRole('radio', { name: 'Feedback' })); + + expect( + rendered.getByRole('button', { name: 'Cancel' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Send Feedback' }), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.tsx b/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.tsx new file mode 100644 index 0000000000..72a204f4e9 --- /dev/null +++ b/plugins/feedback/src/components/CreateFeedbackModal/CreateFeedbackModal.tsx @@ -0,0 +1,322 @@ +import React, { useState } from 'react'; + +import { configApiRef, useApi } from '@backstage/core-plugin-api'; + +import { + Button, + Chip, + createStyles, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Grid, + IconButton, + makeStyles, + Paper, + Radio, + RadioGroup, + TextField, + Theme, + Typography, +} from '@material-ui/core'; +import BugReportOutlined from '@material-ui/icons/BugReportOutlined'; +import BugReportTwoToneIcon from '@material-ui/icons/BugReportTwoTone'; +import CloseRounded from '@material-ui/icons/CloseRounded'; +import SmsOutlined from '@material-ui/icons/SmsOutlined'; +import SmsTwoTone from '@material-ui/icons/SmsTwoTone'; +import { Alert } from '@material-ui/lab'; + +import { feedbackApiRef } from '../../api'; +import { FeedbackCategory } from '../../models/feedback.model'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + '& > * > *': { + margin: theme.spacing(1), + width: '100%', + }, + padding: '0.5rem', + }, + actions: { + '& > *': { + margin: theme.spacing(1), + }, + paddingRight: '1rem', + }, + container: { + padding: '1rem', + }, + dialogTitle: {}, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + radioGroup: { + gap: theme.spacing(3), + }, + }), +); + +const issueTags = [ + 'Slow Loading', + 'Not Responsive', + 'Navigation', + 'UI Issues', + 'Other', +]; +const feedbackTags = ['Excellent', 'Good', 'Needs Improvement', 'Other']; + +export const CreateFeedbackModal = React.forwardRef( + ( + props: { + projectEntity: string; + userEntity: string; + serverType: string; + handleModalCloseFn: (respObj?: any) => void; + }, + ref, + ) => { + const classes = useStyles(); + const api = useApi(feedbackApiRef); + const [feedbackType, setFeedbackType] = useState('BUG'); + const [selectedTag, setSelectedTag] = useState(issueTags[0]); + const app = useApi(configApiRef); + const summaryLimit = app.getOptionalNumber('feedback.summaryLimit') ?? 240; + + const [summary, setSummary] = useState({ + value: '', + error: false, + errorMessage: 'Enter some summary', + }); + const [description, setDescription] = useState({ + value: '', + error: false, + errorMessage: 'Enter some description', + }); + + const projectEntity = props.projectEntity; + const userEntity = props.userEntity; + + function handleCategoryClick(event: any) { + setFeedbackType(event.target.value); + setSelectedTag( + event.target.value === 'FEEDBACK' ? feedbackTags[0] : issueTags[0], + ); + } + + function handleChipSlection(tag: string) { + if (tag === selectedTag) { + return; + } + setSelectedTag(tag); + } + + async function handleSubmitClick() { + const resp = await api.createFeedback({ + summary: summary.value, + description: description.value, + projectId: projectEntity, + url: window.location.href, + userAgent: navigator.userAgent, + createdBy: userEntity, + feedbackType: + feedbackType === 'BUG' + ? FeedbackCategory.BUG + : FeedbackCategory.FEEDBACK, + tag: selectedTag, + }); + props.handleModalCloseFn(resp); + } + + function handleInputChange( + event: React.FocusEvent, + ) { + if (event.target.id === 'summary') { + const _summary = event.target.value; + if (_summary.trim().length === 0) { + return setSummary({ + ...summary, + value: '', + errorMessage: 'Provide summary', + error: true, + }); + } else if (_summary.length > summaryLimit) { + return setSummary({ + ...summary, + value: _summary, + error: true, + errorMessage: `Summary should be less than ${summaryLimit} characters.`, + }); + } + return setSummary({ ...summary, value: _summary, error: false }); + } + if (event.target.id === 'description') { + return setDescription({ + ...description, + value: event.target.value, + error: false, + }); + } + return 0; + } + + function handleValidation( + event: React.FocusEvent, + ) { + if (event.target.id === 'summary') { + if (event.target.value.length === 0) { + return setSummary({ ...summary, error: true }); + } + return setSummary({ + ...summary, + value: event.target.value.trim(), + error: event.target.value.trim().length > summaryLimit, + }); + } + if (event.target.id === 'description' && event.target.value.length > 0) { + setDescription({ ...description, value: description.value.trim() }); + } + return 0; + } + + return ( + + + {`Feedback for ${projectEntity.split('/').pop()}`} + {props.handleModalCloseFn ? ( + + + + ) : null} + + + + + Select type + + } + checkedIcon={} + color="secondary" + /> + } + /> + } + checkedIcon={} + color="primary" + /> + } + /> + + + + + Select {feedbackType === 'FEEDBACK' ? 'Feedback' : 'Bug'} + :  + {feedbackType === 'BUG' + ? issueTags.map(issueTitle => ( + handleChipSlection(issueTitle)} + label={issueTitle} + /> + )) + : feedbackTags.map(feedbackTitle => ( + handleChipSlection(feedbackTitle)} + label={feedbackTitle} + /> + ))} + + + + + + + + + + + + + + + + {props.serverType.toLowerCase() !== 'mail' && + !selectedTag.match(/(Excellent|Good)/g) ? ( + + Note: By submitting  + {feedbackType === 'FEEDBACK' ? 'feedback' : 'bug'} with this tag, it + will create an issue in {props.serverType.toLowerCase()} + + ) : null} + + ); + }, +); diff --git a/plugins/feedback/src/components/CreateFeedbackModal/index.ts b/plugins/feedback/src/components/CreateFeedbackModal/index.ts new file mode 100644 index 0000000000..a5aab37ca2 --- /dev/null +++ b/plugins/feedback/src/components/CreateFeedbackModal/index.ts @@ -0,0 +1 @@ +export { CreateFeedbackModal } from './CreateFeedbackModal'; diff --git a/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/CustomEmptyState.tsx b/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/CustomEmptyState.tsx new file mode 100644 index 0000000000..d728f6653c --- /dev/null +++ b/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/CustomEmptyState.tsx @@ -0,0 +1,167 @@ +import React from 'react'; + +import { CodeSnippet, EmptyState } from '@backstage/core-components'; + +import { + Accordion, + AccordionDetails, + AccordionSummary, + createStyles, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import ExpandMoreRounded from '@material-ui/icons/ExpandMoreRounded'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + code: { + width: '100%', + borderRadius: 6, + margin: theme.spacing(0, 0), + background: theme.palette.background.paper, + }, + accordionGroup: { + '& > *': { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + background: theme.palette.type === 'dark' ? '#333' : '#eee', + }, + }, + heading: { + width: '50%', + fontSize: '1.2rem', + fontWeight: 600, + }, + subHeading: { + color: theme.palette.textSubtle, + }, + embeddedVideo: { + top: '50%', + left: '50%', + zIndex: 2, + position: 'relative', + transform: 'translate(-50%, 15%)', + '& > iframe': { + [theme.breakpoints.up('sm')]: { + width: '100%', + height: '280px', + }, + [theme.breakpoints.up('xl')]: { + width: '768px', + height: '482px', + }, + + border: '0', + borderRadius: theme.spacing(1), + }, + }, + }), +); + +const EMAIL_YAML = `metadata: + annotations: + # Set to MAIL, if you want to recevie mail + # on every feedback. + feedback/type: 'MAIL' + + # Type in your mail here, it will be kept in cc, + # while sending mail on feedback generation. + feedback/email-to: 'example@example.com'`; +const JIRA_YAML = `metadata: + annotations: + # Set to JIRA to create ticket on + # creating feedbacks. + feedback/type: 'JIRA' + + # Enter your jira project key, + jira/project-key: '' + + # Enter the url of you jira server. + feedback/host: '' + + # (optional) Type in your mail here, + # it will be kept in cc, + # while sending mail on feedback generation. + feedback/email-to: 'example@example.com';`; + +export const CustomEmptyState = (props: { [key: string]: string }) => { + const classes = useStyles(); + const [expanded, setExpanded] = React.useState('jira'); + + const handleChange = + (panel: string) => (event: React.ChangeEvent<{}>, isExpanded: boolean) => { + event.preventDefault(); + setExpanded(isExpanded ? panel : false); + }; + + return ( + + Some annotations out of {Object.keys(props).join(', ')}{' '} + are missing. You need to add proper annotations to your component if + you want to enable this tool. + + } + action={ + <> + + Add the annotation to your component YAML as shown in the + highlighted example below: + +
+ + }> + For JIRA + + (An email will be sent if 'feedback/email-to' is set) + + + +
+ +
+
+
+ + }> + For E-Mail + + +
+ +
+
+
+
+ + } + missing="field" + /> + ); +}; diff --git a/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/index.ts b/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/index.ts new file mode 100644 index 0000000000..8c293855d3 --- /dev/null +++ b/plugins/feedback/src/components/EntityFeedbackPage/CustomEmptyState/index.ts @@ -0,0 +1 @@ +export { CustomEmptyState } from './CustomEmptyState'; diff --git a/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.test.tsx b/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.test.tsx new file mode 100644 index 0000000000..68548bfb75 --- /dev/null +++ b/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { ConfigReader } from '@backstage/config'; +import { + BackstageUserIdentity, + configApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { + EntityProvider, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { FeedbackAPI, feedbackApiRef } from '../../api'; +import { mockEntity, mockFeedback } from '../../mocks'; +import { EntityFeedbackPage } from './EntityFeedbackPage'; + +describe('Entity Feedback Page', () => { + const feedbackApi: Partial = { + getAllFeedbacks: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: [mockFeedback], + count: 1, + currentPage: 1, + pageSize: 5, + }); + }), + }; + + const mockIdentityApi: Partial = { + getBackstageIdentity: jest + .fn() + .mockImplementation((): BackstageUserIdentity => { + return { + userEntityRef: 'user:default/guest', + type: 'user', + ownershipEntityRefs: [], + }; + }), + }; + + const mockConfigApi = new ConfigReader({ + feedback: { integrations: { jira: [{ host: 'https://jira-server-url' }] } }, + }); + + const render = async () => + await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/': entityRouteRef, + }, + }, + ); + + it('Should render', async () => { + const rendered = await render(); + expect(rendered).toBeDefined(); + }); + + it('Should have buttons', async () => { + const rendered = await render(); + expect( + rendered.getByRole('button', { name: 'Create' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Refresh' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('link', { name: 'Go to Jira Project' }), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.tsx b/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.tsx new file mode 100644 index 0000000000..554c1ea428 --- /dev/null +++ b/plugins/feedback/src/components/EntityFeedbackPage/EntityFeedbackPage.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from 'react'; + +import { Progress } from '@backstage/core-components'; +import { + configApiRef, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; + +import { + ButtonGroup, + CircularProgress, + createStyles, + Dialog, + makeStyles, + Snackbar, + Theme, + Tooltip, + Zoom, +} from '@material-ui/core'; +import Button from '@material-ui/core/Button'; +import Grid from '@material-ui/core/Grid'; +import Add from '@material-ui/icons/Add'; +import ArrowForwardRounded from '@material-ui/icons/ArrowForwardRounded'; +import Sync from '@material-ui/icons/Sync'; +import { Alert } from '@material-ui/lab'; + +import { CreateFeedbackModal } from '../CreateFeedbackModal/CreateFeedbackModal'; +import { FeedbackDetailsModal } from '../FeedbackDetailsModal'; +import { FeedbackTable } from '../FeedbackTable'; +import { CustomEmptyState } from './CustomEmptyState'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + buttonGroup: { + textAlign: 'center', + whiteSpace: 'nowrap', + marginTop: theme.spacing(1), + }, + }), +); + +export const EntityFeedbackPage = () => { + const config = useApi(configApiRef); + const classes = useStyles(); + const { entity } = useEntity(); + const user = useApi(identityApiRef); + + const [modalProps, setModalProps] = useState<{ + projectEntity: string; + userEntity: string; + serverType: string; + }>(); + + const [modalOpen, setModalOpen] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState<{ + message: string; + open: boolean; + severity: 'success' | 'error'; + }>({ + message: '', + open: false, + severity: 'success', + }); + const [reload, setReload] = useState(false); + useEffect(() => { + setReload(false); + }, [snackbarOpen, reload]); + + const projectEntity = + `${entity.kind}:${entity.metadata.namespace}/${entity.metadata.name}`.toLowerCase(); + + const pluginConfig = { + feedbackType: entity.metadata.annotations!['feedback/type'], + feedbackHost: + entity.metadata.annotations!['feedback/host'] ?? + config + .getConfigArray('feedback.integrations.jira')[0] + .getOptionalString('host'), + feedbackEmailTo: entity.metadata.annotations!['feedback/email-to'], + jiraProjectKey: entity.metadata.annotations!['jira/project-key'], + }; + + async function handleModalOpen() { + const userEntity = (await user.getBackstageIdentity()).userEntityRef; + setModalProps({ + projectEntity: projectEntity, + userEntity: userEntity, + serverType: pluginConfig.feedbackType, + }); + + return setModalOpen(true); + } + + const handleModalClose = (respObj: { [key: string]: string } | false) => { + setModalOpen(false); + if (respObj) { + setReload(true); + if (respObj.error) { + setSnackbarOpen({ + message: respObj.error, + severity: 'error', + open: true, + }); + } else if (respObj.message) { + setSnackbarOpen({ + message: respObj.message, + severity: 'success', + open: true, + }); + } + } + }; + + const handleSnackbarClose = ( + event?: React.SyntheticEvent, + reason?: string, + ) => { + event?.preventDefault(); + if (reason === 'clickaway') { + return; + } + setSnackbarOpen({ ...snackbarOpen, open: false }); + }; + + const handleResyncClick = () => { + setReload(true); + }; + + return pluginConfig.feedbackEmailTo === undefined ? ( + + ) : ( + + + + + + + + {pluginConfig.feedbackType === 'JIRA' ? ( + + + + ) : null} + + + + + + handleModalClose(false)} + aria-labelledby="simple-modal-title" + aria-describedby="simple-modal-description" + fullWidth + maxWidth="md" + > + + + + {reload ? : } + + + + {snackbarOpen?.message} + + + + ); +}; diff --git a/plugins/feedback/src/components/EntityFeedbackPage/index.tsx b/plugins/feedback/src/components/EntityFeedbackPage/index.tsx new file mode 100644 index 0000000000..3bf6e315b0 --- /dev/null +++ b/plugins/feedback/src/components/EntityFeedbackPage/index.tsx @@ -0,0 +1 @@ +export { EntityFeedbackPage } from './EntityFeedbackPage'; diff --git a/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.test.tsx b/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.test.tsx new file mode 100644 index 0000000000..11511ac330 --- /dev/null +++ b/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { + BackstageUserIdentity, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { FeedbackAPI, feedbackApiRef } from '../../api'; +import { mockFeedback, mockJiraDetails } from '../../mocks'; +import { rootRouteRef } from '../../routes'; +import { FeedbackDetailsModal } from './FeedbackDetailsModal'; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + EntityRefLink: (props: { entityRef: string }) => ( + {props.entityRef} + ), +})); + +jest.mock('@backstage/core-components', () => ({ + useQueryParamState: () => [mockFeedback.feedbackId, jest.fn()], + Progress: () => <>, +})); + +describe('Feedback details modal', () => { + const mockFeedbackApi: Partial = { + getFeedbackById: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: mockFeedback, + message: 'Feedback fetched successfully', + }); + }), + getTicketDetails: jest.fn().mockImplementation(() => { + return Promise.resolve(mockJiraDetails.data); + }), + }; + + const mockIdentityApi: Partial = { + getBackstageIdentity: jest + .fn() + .mockImplementation((): BackstageUserIdentity => { + return { + userEntityRef: 'user:default/guest', + type: 'user', + ownershipEntityRefs: [], + }; + }), + }; + + const render = async () => + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + }, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('should render', async () => { + const rendered = await render(); + expect(mockFeedbackApi.getFeedbackById).toHaveBeenCalledTimes(1); + expect(mockFeedbackApi.getTicketDetails).toHaveBeenCalledTimes(1); + expect(rendered).toBeDefined(); + }); + + it('should have correct summary', async () => { + const rendered = await render(); + expect( + rendered.getByRole('heading', { name: mockFeedback.summary }), + ).toBeInTheDocument(); + }); + + it('should have correct description', async () => { + const rendered = await render(); + expect(rendered.getByText(mockFeedback.description)).toBeInTheDocument(); + }); + + it('should have correct user id', async () => { + const rendered = await render(); + expect(rendered.getByText(mockFeedback.createdBy)).toBeInTheDocument(); + }); + + it('should have correct project id', async () => { + const rendered = await render(); + expect(rendered.getByText(mockFeedback.projectId)).toBeInTheDocument(); + }); + + it('should have correct tag', async () => { + const rendered = await render(); + expect(rendered.getByText('Tag')).toBeInTheDocument(); + expect(rendered.getByText(mockFeedback.tag)).toBeInTheDocument(); + }); + + it('should have ticket url', async () => { + const rendered = await render(); + expect(rendered.getByText('Ticket Id')).toBeInTheDocument(); + expect( + rendered.getByText(mockFeedback.ticketUrl.split('/').pop()!), + ).toBeInTheDocument(); + }); + + it('should have correct status field for jira ticket', async () => { + const rendered = await render(); + expect(rendered.getByText('Status')).toBeInTheDocument(); + expect(rendered.getByText(mockJiraDetails.data.status)).toBeInTheDocument(); + }); + + it('should have assignee for jira ticket', async () => { + const rendered = await render(); + expect(rendered.getByText('Assignee')).toBeInTheDocument(); + expect( + rendered.getByText(mockJiraDetails.data.assignee), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.tsx b/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.tsx new file mode 100644 index 0000000000..f028061eba --- /dev/null +++ b/plugins/feedback/src/components/FeedbackDetailsModal/FeedbackDetailsModal.tsx @@ -0,0 +1,356 @@ +import React, { useEffect, useState } from 'react'; + +import { parseEntityRef } from '@backstage/catalog-model'; +import { Progress, useQueryParamState } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; + +import { + Avatar, + Button, + Chip, + createStyles, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + IconButton, + Link, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + makeStyles, + Snackbar, + Theme, + Tooltip, + Typography, + Zoom, +} from '@material-ui/core'; +import ArrowForwardRounded from '@material-ui/icons/ArrowForwardRounded'; +import BugReportOutlined from '@material-ui/icons/BugReportOutlined'; +import CloseRounded from '@material-ui/icons/CloseRounded'; +import ExpandLessRounded from '@material-ui/icons/ExpandLessRounded'; +import ExpandMoreRounded from '@material-ui/icons/ExpandMoreRounded'; +import SmsOutlined from '@material-ui/icons/SmsOutlined'; +import { Alert } from '@material-ui/lab'; + +import { feedbackApiRef } from '../../api'; +import { FeedbackType } from '../../models/feedback.model'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + dialogAction: { + justifyContent: 'flex-start', + paddingLeft: theme.spacing(3), + paddingBottom: theme.spacing(2), + }, + dialogTitle: { + '& > *': { + display: 'flex', + alignItems: 'center', + marginRight: theme.spacing(2), + wordBreak: 'break-word', + }, + '& > * > svg': { marginRight: theme.spacing(1) }, + + paddingBottom: theme.spacing(0), + }, + submittedBy: { + color: theme.palette.textSubtle, + fontWeight: 500, + }, + readMoreLink: { + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }, + }), +); + +export const FeedbackDetailsModal = () => { + const classes = useStyles(); + const api = useApi(feedbackApiRef); + const [queryState, setQueryState] = useQueryParamState( + 'id', + ); + const [modalData, setModalData] = useState(); + const [snackbarOpen, setSnackbarOpen] = useState({ + message: '', + open: false, + }); + + const [ticketDetails, setTicketDetails] = useState<{ + status: string | null; + assignee: string | null; + avatarUrls: {} | null; + element: React.JSX.Element | null; + }>({ status: null, assignee: null, avatarUrls: null, element: null }); + + const [isLoading, setIsLoading] = useState(true); + const [expandDescription, setExpandDescription] = useState(false); + + useEffect(() => { + if (modalData?.ticketUrl) { + api + .getTicketDetails( + modalData.feedbackId, + modalData.ticketUrl, + modalData.projectId, + ) + .then(data => { + setTicketDetails({ + status: data.status, + assignee: data.assignee, + avatarUrls: data.avatarUrls, + element: ( + <> + + + + } /> + + + + + + + } + label={data.assignee ? data.assignee : 'Unassigned'} + /> + } + /> + + + + ), + }); + setIsLoading(false); + }); + } else { + setIsLoading(false); + } + if (!modalData && queryState) { + api.getFeedbackById(queryState).then(resp => { + if (resp?.error !== undefined) { + setSnackbarOpen({ message: resp.error, open: true }); + } else { + const respData: FeedbackType = resp?.data!; + setModalData(respData); + } + }); + } + }, [modalData, api, queryState]); + + const handleSnackbarClose = ( + event?: React.SyntheticEvent, + reason?: string, + ) => { + event?.preventDefault(); + if (reason === 'clickaway') { + return; + } + setSnackbarOpen({ ...snackbarOpen, open: false }); + setQueryState(undefined); + }; + + const handleClose = () => { + setModalData(undefined); + setTicketDetails({ + status: null, + assignee: null, + avatarUrls: null, + element: null, + }); + setIsLoading(true); + setExpandDescription(false); + setQueryState(undefined); + }; + + const getDescription = (str: string) => { + if (!expandDescription) { + if (str.length > 400) { + if (str.split(' ').length > 1) + return `${str.substring(0, str.lastIndexOf(' ', 400))}...`; + return `${str.slice(0, 400)}...`; + } + } + return str; + }; + + return ( + + {!modalData ? ( + + + {snackbarOpen.message} + + + ) : ( + <> + + + {modalData.feedbackType === 'FEEDBACK' ? ( + + ) : ( + + )} + + {modalData.summary} + + + + + + + + + Submitted by  + + {parseEntityRef(modalData.createdBy).name} + {' '} + on {new Date(modalData.createdAt).toLocaleString()} + + + + + {modalData.description + ? getDescription(modalData.description) + : 'No description provided'} + + {modalData.description.length > 400 ? ( + setExpandDescription(!expandDescription)} + > + {!expandDescription ? ( + + ) : ( + + )} + {!expandDescription ? 'Read More' : 'Read Less'} + + ) : null} + + + + + + + + + + } + /> + + + + + + + } + /> + + + {modalData.ticketUrl ? ( + + + + + + + } + /> + + + ) : null} + {isLoading ? : ticketDetails.element} + + + + + {modalData.ticketUrl ? ( + + + + ) : null} + + )} + + ); +}; diff --git a/plugins/feedback/src/components/FeedbackDetailsModal/index.ts b/plugins/feedback/src/components/FeedbackDetailsModal/index.ts new file mode 100644 index 0000000000..ac3a2e027a --- /dev/null +++ b/plugins/feedback/src/components/FeedbackDetailsModal/index.ts @@ -0,0 +1 @@ +export { FeedbackDetailsModal } from './FeedbackDetailsModal'; diff --git a/plugins/feedback/src/components/FeedbackTable/FeedbackTable.test.tsx b/plugins/feedback/src/components/FeedbackTable/FeedbackTable.test.tsx new file mode 100644 index 0000000000..b1d6516b3f --- /dev/null +++ b/plugins/feedback/src/components/FeedbackTable/FeedbackTable.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +import { waitFor } from '@testing-library/react'; + +import { FeedbackAPI, feedbackApiRef } from '../../api'; +import { mockFeedback } from '../../mocks'; +import { rootRouteRef } from '../../routes'; +import { FeedbackTable } from './FeedbackTable'; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + EntityRefLink: (props: { entityRef: string }) => ( + {props.entityRef} + ), + EntityPeekAheadPopover: (props: { children?: React.ReactNode }) => ( + <>{props.children} + ), +})); + +describe('Feedback Table Component', () => { + const mockFeedbackApi: Partial = { + getAllFeedbacks: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: [mockFeedback], + count: 1, + currentPage: 1, + pageSize: 5, + }); + }), + }; + + const render = async () => + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render', async () => { + const rendered = await render(); + await waitFor(() => { + expect(rendered).toBeDefined(); + expect(mockFeedbackApi.getAllFeedbacks).toHaveBeenCalledTimes(1); + }); + }); + + it('should render all columns in table table', async () => { + const rendered = await render(); + expect(rendered.getByText('Summary')).toBeInTheDocument(); + expect(rendered.getByText('Type')).toBeInTheDocument(); + expect(rendered.getByText('Project')).toBeInTheDocument(); + expect(rendered.getByText('Ticket')).toBeInTheDocument(); + expect(rendered.getByText('Tag')).toBeInTheDocument(); + }); + + it('should render data in table', async () => { + const rendered = await render(); + await waitFor(() => { + expect(rendered.getByText(mockFeedback.summary)).toBeInTheDocument(); + expect(rendered.getByText(mockFeedback.projectId)).toBeInTheDocument(); + expect( + rendered.getByText(mockFeedback.ticketUrl.split('/').pop()!), + ).toBeInTheDocument(); + expect(rendered.getByText(mockFeedback.tag)).toBeInTheDocument(); + }); + }); + + it('should have pagination buttons', async () => { + const rendered = await render(); + await waitFor(() => { + expect( + rendered.getByRole('button', { name: 'First Page' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Previous Page' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Next Page' }), + ).toBeInTheDocument(); + expect( + rendered.getByRole('button', { name: 'Last Page' }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/feedback/src/components/FeedbackTable/FeedbackTable.tsx b/plugins/feedback/src/components/FeedbackTable/FeedbackTable.tsx new file mode 100644 index 0000000000..df19f98e42 --- /dev/null +++ b/plugins/feedback/src/components/FeedbackTable/FeedbackTable.tsx @@ -0,0 +1,292 @@ +import React, { useState } from 'react'; +import { useDebounce } from 'react-use'; + +import { parseEntityRef } from '@backstage/catalog-model'; +import { + SubvalueCell, + Table, + TableColumn, + useQueryParamState, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { + EntityPeekAheadPopover, + EntityRefLink, +} from '@backstage/plugin-catalog-react'; + +import { + Box, + Chip, + IconButton, + Link, + makeStyles, + Paper, + TableContainer, + TextField, + Theme, + Tooltip, + Typography, +} from '@material-ui/core'; +import BugReportOutlined from '@material-ui/icons/BugReportOutlined'; +import Clear from '@material-ui/icons/Clear'; +import Search from '@material-ui/icons/Search'; +import TextsmsOutlined from '@material-ui/icons/TextsmsOutlined'; + +import { feedbackApiRef } from '../../api'; +import { FeedbackType } from '../../models/feedback.model'; + +const useStyles = makeStyles((theme: Theme) => ({ + textField: { + padding: 0, + margin: theme.spacing(2), + width: '70%', + [theme.breakpoints.up('lg')]: { + width: '30%', + }, + }, +})); + +export const FeedbackTable: React.FC<{ projectId?: string }> = (props: { + projectId?: string; +}) => { + const projectId = props.projectId ? props.projectId : 'all'; + const api = useApi(feedbackApiRef); + const [feedbackData, setFeedbackData] = useState([]); + const [tableConfig, setTableConfig] = useState({ + totalFeedbacks: 100, + page: 1, + pageSize: 5, + }); + const [queryState, setQueryState] = useQueryParamState('id'); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(''); + const classes = useStyles(); + + const columns: TableColumn[] = [ + { + width: '1%', + field: 'feedbackType', + title: 'Type', + render: (row: any) => { + const data: FeedbackType = row; + return data.feedbackType === 'BUG' ? ( + + ) : ( + + ); + }, + }, + { + field: 'summary', + title: 'Summary', + render: (row: any) => { + const data: FeedbackType = row; + const getSummary = () => { + if (data.summary.length > 100) { + if (data.summary.split(' ').length > 1) + return `${data.summary.substring( + 0, + data.summary.lastIndexOf(' ', 100), + )}...`; + return `${data.summary.slice(0, 100)}...`; + } + return data.summary; + }; + return ( + {getSummary()}} + subvalue={ +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + tabIndex={0} + role="tab" + > + + Submitted by  + + {parseEntityRef(data.createdBy).name} + + +
+ } + /> + ); + }, + }, + { + field: 'projectId', + title: 'Project', + render: (row: any) => { + const data: FeedbackType = row; + return ( + + {parseEntityRef(data.projectId).name} + + ); + }, + align: 'center', + width: '40%', + }, + { + align: 'left', + field: 'ticketUrl', + title: 'Ticket', + disableClick: true, + width: '10%', + render: (row: any) => { + const data: FeedbackType = row; + return data.ticketUrl ? ( + + {data.ticketUrl.split('/').pop()} + + ) : ( + 'N/A' + ); + }, + }, + { + title: 'Tag', + customSort: (data1: any, data2: any) => { + const currentRow: FeedbackType = data1; + const nextRow: FeedbackType = data2; + return currentRow.tag + .toLowerCase() + .localeCompare(nextRow.tag.toLowerCase()); + }, + render: (row: any) => { + const data: FeedbackType = row; + return ( + + ); + }, + }, + ]; + + useDebounce( + () => { + api + .getAllFeedbacks(1, tableConfig.pageSize, projectId, searchText) + .then(data => { + setFeedbackData(data.data); + setTableConfig({ + totalFeedbacks: data.count, + page: data.currentPage, + pageSize: data.pageSize, + }); + setLoading(false); + }) + .finally(() => { + setLoading(false); + }); + }, + 400, + [projectId, api, tableConfig.pageSize, searchText], + ); + + async function handlePageChange(newPage: number, pageSize: number) { + if (newPage > tableConfig.page) { + setTableConfig({ + totalFeedbacks: tableConfig.totalFeedbacks, + pageSize: pageSize, + page: newPage - 1, + }); + } + setTableConfig({ + totalFeedbacks: tableConfig.totalFeedbacks, + pageSize: pageSize, + page: newPage + 1, + }); + const newData = await api.getAllFeedbacks( + newPage + 1, + pageSize, + projectId, + searchText, + ); + return setFeedbackData(newData.data); + } + + async function handleChangeRowsPerPage(pageSize: number) { + setTableConfig({ + ...tableConfig, + pageSize: pageSize, + }); + const newData = await api.getAllFeedbacks( + tableConfig.page, + pageSize, + projectId, + searchText, + ); + return setFeedbackData(newData.data); + } + + function handleRowClick( + event?: React.MouseEvent | undefined, + rowData?: any, + ) { + event?.preventDefault(); + const data: FeedbackType = rowData; + if (!queryState) setQueryState(data.feedbackId); + } + + function handleSearch( + event: React.ChangeEvent, + ) { + const _searchText = event.target.value; + if (_searchText.trim().length !== 0) return setSearchText(_searchText); + return setSearchText(''); + } + + return ( + + + + + ), + endAdornment: ( + + setSearchText('')}> + + + + ), + }} + /> + + 0, + pageSizeOptions: [5, 10, 25], + pageSize: tableConfig.pageSize, + paginationPosition: 'bottom', + padding: 'dense', + toolbar: false, + search: false, + sorting: false, + emptyRowsWhenPaging: false, + }} + isLoading={loading} + onRowClick={handleRowClick} + onPageChange={handlePageChange} + onRowsPerPageChange={handleChangeRowsPerPage} + data={feedbackData} + columns={columns} + totalCount={tableConfig.totalFeedbacks} + page={tableConfig.page - 1} + /> + + + ); +}; diff --git a/plugins/feedback/src/components/FeedbackTable/index.tsx b/plugins/feedback/src/components/FeedbackTable/index.tsx new file mode 100644 index 0000000000..09eaeab295 --- /dev/null +++ b/plugins/feedback/src/components/FeedbackTable/index.tsx @@ -0,0 +1 @@ +export { FeedbackTable } from './FeedbackTable'; diff --git a/plugins/feedback/src/components/GlobalFeedbackPage/GlobalFeedbackPage.tsx b/plugins/feedback/src/components/GlobalFeedbackPage/GlobalFeedbackPage.tsx new file mode 100644 index 0000000000..14d70800b3 --- /dev/null +++ b/plugins/feedback/src/components/GlobalFeedbackPage/GlobalFeedbackPage.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { Content, Header, Page } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; + +import { Grid } from '@material-ui/core'; + +import { FeedbackDetailsModal } from '../FeedbackDetailsModal'; +import { FeedbackTable } from '../FeedbackTable'; + +export const GlobalFeedbackPage = () => { + const app = useApi(configApiRef); + const appTitle = app.getString('app.title'); + return ( + +
+ + + + + + + + + + ); +}; diff --git a/plugins/feedback/src/components/GlobalFeedbackPage/index.ts b/plugins/feedback/src/components/GlobalFeedbackPage/index.ts new file mode 100644 index 0000000000..6cb37a91e9 --- /dev/null +++ b/plugins/feedback/src/components/GlobalFeedbackPage/index.ts @@ -0,0 +1 @@ +export { GlobalFeedbackPage } from './GlobalFeedbackPage'; diff --git a/plugins/feedback/src/components/OpcFeedbackComponent/OpcFeedbackComponent.tsx b/plugins/feedback/src/components/OpcFeedbackComponent/OpcFeedbackComponent.tsx new file mode 100644 index 0000000000..1af9230b69 --- /dev/null +++ b/plugins/feedback/src/components/OpcFeedbackComponent/OpcFeedbackComponent.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from 'react'; + +import '@one-platform/opc-feedback'; + +import { + configApiRef, + identityApiRef, + useApi, + useRouteRef, +} from '@backstage/core-plugin-api'; + +import { Snackbar } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; + +import { feedbackApiRef } from '../../api'; +import { FeedbackCategory } from '../../models/feedback.model'; +import { rootRouteRef, viewDocsRouteRef } from '../../routes'; + +export const OpcFeedbackComponent = () => { + const appConfig = useApi(configApiRef); + const feedbackApi = useApi(feedbackApiRef); + const identityApi = useApi(identityApiRef); + + const footer = JSON.stringify({ + name: appConfig.getString('app.title'), + url: appConfig.getString('app.baseUrl'), + }); + const projectId = appConfig.getString('feedback.baseEntityRef'); + const summaryLimit = + appConfig.getOptionalNumber('feedback.summaryLimit') ?? 240; + const docsSpa = useRouteRef(viewDocsRouteRef); + const feedbackSpa = useRouteRef(rootRouteRef); + + const [snackbarState, setSnackbarState] = React.useState<{ + message?: string; + open: boolean; + severity: 'success' | 'error'; + }>({ + message: '', + open: false, + severity: 'success', + }); + + const handleSnackbarClose = ( + event?: React.SyntheticEvent, + reason?: string, + ) => { + event?.preventDefault(); + if (reason === 'clickaway') { + return; + } + setSnackbarState({ ...snackbarState, open: false }); + }; + + useEffect(() => { + const onSubmit = async (event: any) => { + if (event.detail.data.summary.trim().length < 1) { + setSnackbarState({ + message: 'Summary cannot be empty', + severity: 'error', + open: true, + }); + throw Error('Summary cannot be empty'); + } + const userEntity = (await identityApi.getBackstageIdentity()) + .userEntityRef; + const resp = await feedbackApi.createFeedback({ + summary: event.detail.data.summary, + description: '', + projectId: projectId, + url: window.location.href, + userAgent: navigator.userAgent, + createdBy: userEntity, + tag: + event.detail.data.category === 'BUG' + ? event.detail.data.error + : event.detail.data.experience, + feedbackType: + event.detail.data.category === 'BUG' + ? FeedbackCategory.BUG + : FeedbackCategory.FEEDBACK, + }); + if (resp.error) { + setSnackbarState({ + message: resp.error, + severity: 'error', + open: true, + }); + throw new Error(resp.error); + } else { + setSnackbarState({ + message: resp.message, + severity: 'success', + open: true, + }); + } + }; + const elem: any = document.querySelector('opc-feedback'); + elem.onSubmit = onSubmit; + }, [feedbackApi, projectId, identityApi]); + + return ( + <> + + + {snackbarState?.message} + + + + + ); +}; diff --git a/plugins/feedback/src/components/OpcFeedbackComponent/declaration.d.ts b/plugins/feedback/src/components/OpcFeedbackComponent/declaration.d.ts new file mode 100644 index 0000000000..0f7d91f282 --- /dev/null +++ b/plugins/feedback/src/components/OpcFeedbackComponent/declaration.d.ts @@ -0,0 +1,12 @@ +declare module '@one-platform/opc-feedback'; +declare namespace JSX { + interface IntrinsicElements { + 'opc-feedback': { + spa: string; + docs: string; + summaryLimit: number; + theme: string; + app: string; + }; + } +} diff --git a/plugins/feedback/src/components/OpcFeedbackComponent/index.ts b/plugins/feedback/src/components/OpcFeedbackComponent/index.ts new file mode 100644 index 0000000000..7a553d5d9c --- /dev/null +++ b/plugins/feedback/src/components/OpcFeedbackComponent/index.ts @@ -0,0 +1 @@ +export { OpcFeedbackComponent } from './OpcFeedbackComponent'; diff --git a/plugins/feedback/src/index.ts b/plugins/feedback/src/index.ts new file mode 100644 index 0000000000..8d6684dbbb --- /dev/null +++ b/plugins/feedback/src/index.ts @@ -0,0 +1,6 @@ +export { + feedbackPlugin, + GlobalFeedbackPage, + EntityFeedbackPage, + OpcFeedbackComponent, +} from './plugin'; diff --git a/plugins/feedback/src/mocks/entity.ts b/plugins/feedback/src/mocks/entity.ts new file mode 100644 index 0000000000..9c70802e86 --- /dev/null +++ b/plugins/feedback/src/mocks/entity.ts @@ -0,0 +1,22 @@ +import { Entity } from '@backstage/catalog-model'; + +export const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'example-website-for-feedback-plugin', + title: 'Example App', + namespace: 'default', + annotations: { + 'feedback/type': 'JIRA', + 'feedback/host': 'https://jira-host-url', + 'feedback/email-to': 'example@email.com', + 'jira/project-key': 'XYZ', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }, +}; diff --git a/plugins/feedback/src/mocks/feedback.ts b/plugins/feedback/src/mocks/feedback.ts new file mode 100644 index 0000000000..d626840c62 --- /dev/null +++ b/plugins/feedback/src/mocks/feedback.ts @@ -0,0 +1,16 @@ +export const mockFeedback = { + feedbackId: 'bubuPsc93VRYAwByZe8ZQ9', + summary: 'Test Issue', + description: 'This is mock description', + tag: 'UI Issues', + projectId: 'component:default/example-website-for-feedback-plugin', + ticketUrl: 'https://demo-ticket-url/ticket-id', + feedbackType: 'BUG', + createdBy: 'user:default/guest', + createdAt: '2023-11-21T05:41:56.100Z', + updatedBy: 'user:default/guest', + updatedAt: '2023-11-21T05:41:56.100Z', + url: 'http://localhost:3000/catalog/default/component/example-website-for-feedback-plugin/feedback', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0', +}; diff --git a/plugins/feedback/src/mocks/index.ts b/plugins/feedback/src/mocks/index.ts new file mode 100644 index 0000000000..8844a279ec --- /dev/null +++ b/plugins/feedback/src/mocks/index.ts @@ -0,0 +1,3 @@ +export * from './entity'; +export * from './feedback'; +export * from './jiraDetails'; diff --git a/plugins/feedback/src/mocks/jiraDetails.ts b/plugins/feedback/src/mocks/jiraDetails.ts new file mode 100644 index 0000000000..cc2e7c9a3d --- /dev/null +++ b/plugins/feedback/src/mocks/jiraDetails.ts @@ -0,0 +1,17 @@ +export const mockJiraDetails = { + data: { + status: 'Code Review', + assignee: 'John Doe', + avatarUrls: { + '48x48': + 'https://www.gravatar.com/avatar/d18762da9985fd2c38032dbe4568472f?d=mm&s=48', + '24x24': + 'https://www.gravatar.com/avatar/d18762da9985fd2c38032dbe4568472f?d=mm&s=24', + '16x16': + 'https://www.gravatar.com/avatar/d18762da9985fd2c38032dbe4568472f?d=mm&s=16', + '32x32': + 'https://www.gravatar.com/avatar/d18762da9985fd2c38032dbe4568472f?d=mm&s=32', + }, + }, + message: 'fetched successfully', +}; diff --git a/plugins/feedback/src/models/feedback.model.ts b/plugins/feedback/src/models/feedback.model.ts new file mode 100644 index 0000000000..b652fcb287 --- /dev/null +++ b/plugins/feedback/src/models/feedback.model.ts @@ -0,0 +1,20 @@ +export enum FeedbackCategory { + BUG = 'BUG', + FEEDBACK = 'FEEDBACK', +} + +export type FeedbackType = { + feedbackId: string; + summary: string; + projectId: string; + description: string; + url: string; + userAgent: string; + tag: string; + ticketUrl: string; + feedbackType: FeedbackCategory; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +}; diff --git a/plugins/feedback/src/plugin.test.ts b/plugins/feedback/src/plugin.test.ts new file mode 100644 index 0000000000..31ca4c6c8e --- /dev/null +++ b/plugins/feedback/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { feedbackPlugin } from './plugin'; + +describe('feedback', () => { + it('should export plugin', () => { + expect(feedbackPlugin).toBeDefined(); + }); +}); diff --git a/plugins/feedback/src/plugin.ts b/plugins/feedback/src/plugin.ts new file mode 100644 index 0000000000..74c606d47f --- /dev/null +++ b/plugins/feedback/src/plugin.ts @@ -0,0 +1,66 @@ +import { + configApiRef, + createApiFactory, + createComponentExtension, + createPlugin, + createRoutableExtension, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; + +import { FeedbackAPI, feedbackApiRef } from './api'; +import { entityRootRouteRef, rootRouteRef, viewDocsRouteRef } from './routes'; + +export const feedbackPlugin = createPlugin({ + id: 'feedback', + routes: { + root: rootRouteRef, + entityRoot: entityRootRouteRef, + }, + externalRoutes: { + viewDocs: viewDocsRouteRef, + }, + apis: [ + createApiFactory({ + api: feedbackApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + identityApi: identityApiRef, + }, + factory: ({ discoveryApi, configApi, identityApi }) => { + return new FeedbackAPI({ discoveryApi, configApi, identityApi }); + }, + }), + ], +}); + +export const GlobalFeedbackPage = feedbackPlugin.provide( + createRoutableExtension({ + name: 'GlobalFeedbackPage', + component: () => + import('./components/GlobalFeedbackPage').then(m => m.GlobalFeedbackPage), + mountPoint: rootRouteRef, + }), +); + +export const EntityFeedbackPage = feedbackPlugin.provide( + createRoutableExtension({ + name: 'EntityFeedbackPage', + component: () => + import('./components/EntityFeedbackPage').then(m => m.EntityFeedbackPage), + mountPoint: entityRootRouteRef, + }), +); + +export const OpcFeedbackComponent = feedbackPlugin.provide( + createComponentExtension({ + name: 'OpcFeedbackComponent', + component: { + lazy: () => + import('./components/OpcFeedbackComponent').then( + m => m.OpcFeedbackComponent, + ), + }, + }), +); diff --git a/plugins/feedback/src/routes.ts b/plugins/feedback/src/routes.ts new file mode 100644 index 0000000000..652251f3a6 --- /dev/null +++ b/plugins/feedback/src/routes.ts @@ -0,0 +1,17 @@ +import { + createExternalRouteRef, + createRouteRef, +} from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'feedback:global-page', +}); + +export const viewDocsRouteRef = createExternalRouteRef({ + id: 'view-docs', +}); + +export const entityRootRouteRef = createRouteRef({ + id: 'feedback:entity-page', + params: ['namespace', 'kind', 'name'], +}); diff --git a/plugins/feedback/src/setupTests.ts b/plugins/feedback/src/setupTests.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/plugins/feedback/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/plugins/feedback/tsconfig.json b/plugins/feedback/tsconfig.json new file mode 100644 index 0000000000..3e73092409 --- /dev/null +++ b/plugins/feedback/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src", "dev"], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "../../dist-types/plugins/feedback", + "rootDir": "." + } +} diff --git a/plugins/feedback/turbo.json b/plugins/feedback/turbo.json new file mode 100644 index 0000000000..604151ad4c --- /dev/null +++ b/plugins/feedback/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "pipeline": { + "tsc": { + "outputs": ["../../dist-types/plugins/feedback/**"], + "dependsOn": ["^tsc"] + } + } +} diff --git a/yarn.lock b/yarn.lock index c72e6005fe..a43d585d6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3068,6 +3068,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.10.2", "@babel/runtime@^7.20.13": + version "7.23.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" + integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.23.2": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.4.tgz#36fa1d2b36db873d25ec631dcc4923fdc1cf2e2e" @@ -3075,13 +3082,6 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.20.13": - version "7.23.8" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" - integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== - dependencies: - regenerator-runtime "^0.14.0" - "@babel/runtime@^7.20.6": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" @@ -3734,7 +3734,7 @@ cross-fetch "^4.0.0" uri-template "^2.0.0" -"@backstage/catalog-client@^1.5.2": +"@backstage/catalog-client@^1.5.1", "@backstage/catalog-client@^1.5.2": version "1.5.2" resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.5.2.tgz#f75e14e4e3aa473fc5db47841f531d1833e611e8" integrity sha512-hWP1Zb2KZ7owSvHdOhP+VB8eSOYbnsXz+l2OdTgMhKQS8ulGZXUW1SzA+N9PZupnQLYmZP2+2DXTpKhSEzQnnQ== @@ -5037,7 +5037,7 @@ yaml "^2.0.0" zen-observable "^0.10.0" -"@backstage/plugin-catalog-react@^1.9.3": +"@backstage/plugin-catalog-react@^1.9.2", "@backstage/plugin-catalog-react@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.9.3.tgz#d5910989bc62e1827be00bc4e9650985f2ea338e" integrity sha512-JeJp4uGiC4gFmCRV8Pk50rzKxAtvKZFuMZ1N7n7t39NtvcmKJemrYKE+5q9RMGi/hRE5+i2D0tqX90JDKlNdVA== @@ -8017,6 +8017,18 @@ dependencies: "@lezer/common" "^1.0.0" +"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312" + integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g== + +"@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03" + integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.0.0" + "@lukeed/csprng@^1.0.0", "@lukeed/csprng@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" @@ -9382,6 +9394,16 @@ "@octokit/webhooks-types" "6.11.0" aggregate-error "^3.1.0" +"@one-platform/opc-feedback@0.1.1-alpha": + version "0.1.1-alpha" + resolved "https://registry.yarnpkg.com/@one-platform/opc-feedback/-/opc-feedback-0.1.1-alpha.tgz#f0481f5869a7e867ed3179197fae362c7cc16c4a" + integrity sha512-5H1TGy25YNPU6CDLOzsiKwgFGd9BMVXO2zDfLdmHLWSYP9cGtFIv3I87qCssBrsPh6ltZzIgDpbd8kyMpF6pbQ== + dependencies: + dialog-polyfill "^0.5.6" + lit "^2.0.0-rc.2" + lit-analyzer "^1.2.1" + lit-element "2.4.0" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -13431,7 +13453,7 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@5.17.0", "@testing-library/jest-dom@^5.17.0": +"@testing-library/jest-dom@5.17.0", "@testing-library/jest-dom@^5.10.1", "@testing-library/jest-dom@^5.17.0": version "5.17.0" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c" integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg== @@ -13454,7 +13476,7 @@ "@babel/runtime" "^7.12.5" react-error-boundary "^3.1.0" -"@testing-library/react@12.1.5", "@testing-library/react@^12.1.5": +"@testing-library/react@12.1.5", "@testing-library/react@^12.1.3", "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== @@ -13468,7 +13490,7 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f" integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== -"@testing-library/user-event@^14.5.1": +"@testing-library/user-event@^14.0.0", "@testing-library/user-event@^14.5.1": version "14.5.2" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== @@ -14404,6 +14426,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.6.tgz#26da694f75cdb057750f49d099da5e3f3824cb3e" integrity sha512-wf3Vz+jCmOQ2HV1YUJuCWdL64adYxumkrxtc+H1VUQlnQI04+5HtH+qZCOE21lBE7gIrt+CwX2Wv8Acrw5Ak6w== +"@types/nodemailer@^6.4.14": + version "6.4.14" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.14.tgz#5c81a5e856db7f8ede80013e6dbad7c5fb2283e2" + integrity sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA== + dependencies: + "@types/node" "*" + "@types/normalize-package-data@^2.4.0": version "2.4.3" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.3.tgz#291c243e4b94dbfbc0c0ee26b7666f1d5c030e2c" @@ -14708,7 +14737,7 @@ "@types/cookiejar" "*" "@types/node" "*" -"@types/supertest@2.0.16": +"@types/supertest@2.0.16", "@types/supertest@^2.0.12": version "2.0.16" resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.16.tgz#7a1294edebecb960d957bbe9b26002a2b7f21cd7" integrity sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg== @@ -14744,6 +14773,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65" integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ== +"@types/trusted-types@^2.0.2": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/tunnel@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.3.tgz#f109e730b072b3136347561fc558c9358bb8c6e9" @@ -15545,6 +15579,11 @@ antlr4@^4.13.0: resolved "https://registry.yarnpkg.com/antlr4/-/antlr4-4.13.1.tgz#1e0a1830a08faeb86217cb2e6c34716004e4253d" integrity sha512-kiXTspaRYvnIArgE97z5YVVf/cDVQABr3abFRR6mE7yesLMkgu4ujuyV/sgxafQ8wgve0DJQUJ38Z8tkgA2izA== +any-base@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe" + integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg== + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -16077,7 +16116,7 @@ axios@0.27.2, axios@^0.27.2: follow-redirects "^1.14.9" form-data "^4.0.0" -axios@>=0.25.0, axios@^1.3.4: +axios@>=0.25.0, axios@^1.3.4, axios@^1.6.4: version "1.6.5" resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.5.tgz#2c090da14aeeab3770ad30c3a1461bc970fb0cd8" integrity sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg== @@ -16944,7 +16983,7 @@ camelcase@5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== -camelcase@^5.3.1: +camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== @@ -17334,6 +17373,15 @@ client-only@^0.0.1: resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -18845,7 +18893,7 @@ decamelize-keys@^1.1.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0: +decamelize@^1.1.0, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== @@ -19167,6 +19215,20 @@ dezalgo@^1.0.0, dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" +dialog-polyfill@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a" + integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w== + +didyoumean2@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/didyoumean2/-/didyoumean2-4.1.0.tgz#f813cb7c82c249443e599be077f76e88f24b85e4" + integrity sha512-qTBmfQoXvhKO75D/05C8m+fteQmn4U46FWYiLhXtZQInzitXLWY0EQ/2oKnpAz9g2lQWW8jYcLcT+hPJGT+kig== + dependencies: + "@babel/runtime" "^7.10.2" + leven "^3.1.0" + lodash.deburr "^4.1.0" + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -20659,6 +20721,17 @@ fast-glob@^3.1.1, fast-glob@^3.2.9, fast-glob@^3.3.0: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.2.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-parse@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d" @@ -21471,7 +21544,7 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-caller-file@^2.0.5: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -24720,13 +24793,13 @@ knex-mock-client@2.0.0: dependencies: lodash.clonedeep "^4.5.0" -knex@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/knex/-/knex-2.5.1.tgz#a6c6b449866cf4229f070c17411f23871ba52ef9" - integrity sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA== +knex@2.4.2, knex@^2.0.0, knex@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.4.2.tgz#a34a289d38406dc19a0447a78eeaf2d16ebedd61" + integrity sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg== dependencies: colorette "2.0.19" - commander "^10.0.0" + commander "^9.1.0" debug "4.3.4" escalade "^3.1.1" esm "^3.2.25" @@ -24734,19 +24807,19 @@ knex@2.5.1: getopts "2.3.0" interpret "^2.2.0" lodash "^4.17.21" - pg-connection-string "2.6.1" + pg-connection-string "2.5.0" rechoir "^0.8.0" resolve-from "^5.0.0" tarn "^3.0.2" tildify "2.0.0" -knex@^2.0.0, knex@^2.3.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/knex/-/knex-2.4.2.tgz#a34a289d38406dc19a0447a78eeaf2d16ebedd61" - integrity sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg== +knex@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.5.1.tgz#a6c6b449866cf4229f070c17411f23871ba52ef9" + integrity sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA== dependencies: colorette "2.0.19" - commander "^9.1.0" + commander "^10.0.0" debug "4.3.4" escalade "^3.1.1" esm "^3.2.25" @@ -24754,7 +24827,7 @@ knex@^2.0.0, knex@^2.3.0: getopts "2.3.0" interpret "^2.2.0" lodash "^4.17.21" - pg-connection-string "2.5.0" + pg-connection-string "2.6.1" rechoir "^0.8.0" resolve-from "^5.0.0" tarn "^3.0.2" @@ -25288,6 +25361,57 @@ listr2@7.0.1: rfdc "^1.3.0" wrap-ansi "^8.1.0" +lit-analyzer@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/lit-analyzer/-/lit-analyzer-1.2.1.tgz#725331a4019ae870dd631d4dd709d39a237161ea" + integrity sha512-OEARBhDidyaQENavLbzpTKbEmu5rnAI+SdYsH4ia1BlGlLiqQXoym7uH1MaRPtwtUPbkhUfT4OBDZ+74VHc3Cg== + dependencies: + chalk "^2.4.2" + didyoumean2 "4.1.0" + fast-glob "^2.2.6" + parse5 "5.1.0" + ts-simple-type "~1.0.5" + vscode-css-languageservice "4.3.0" + vscode-html-languageservice "3.1.0" + web-component-analyzer "~1.1.1" + +lit-element@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452" + integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg== + dependencies: + lit-html "^1.1.1" + +lit-element@^3.3.0: + version "3.3.3" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209" + integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.1.0" + "@lit/reactive-element" "^1.3.0" + lit-html "^2.8.0" + +lit-html@^1.1.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0" + integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA== + +lit-html@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa" + integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^2.0.0-rc.2: + version "2.8.0" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e" + integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA== + dependencies: + "@lit/reactive-element" "^1.6.0" + lit-element "^3.3.0" + lit-html "^2.8.0" + load-json-file@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" @@ -25404,6 +25528,11 @@ lodash.debounce@^4, lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.deburr@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b" + integrity sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ== + lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" @@ -27203,6 +27332,11 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +nodemailer@^6.9.8: + version "6.9.8" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.8.tgz#29601e80440f2af7aa62b32758fdac7c6b784143" + integrity sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ== + nodemon@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.0.1.tgz#affe822a2c5f21354466b2fc8ae83277d27dadc7" @@ -28545,6 +28679,11 @@ parse5-htmlparser2-tree-adapter@^6.0.0: dependencies: parse5 "^6.0.1" +parse5@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" + integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ== + parse5@6.0.1, parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" @@ -31026,6 +31165,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + requireindex@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" @@ -31777,6 +31921,14 @@ short-unique-id@^5.0.2: resolved "https://registry.yarnpkg.com/short-unique-id/-/short-unique-id-5.0.3.tgz#bc6975dc5e8b296960ff5ac91ddabbc7ddb693d9" integrity sha512-yhniEILouC0s4lpH0h7rJsfylZdca10W9mDJRAFh3EpcSUanCHGb0R7kcFOIUCZYSAPo0PUD5ZxWQdW0T4xaug== +short-uuid@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/short-uuid/-/short-uuid-4.2.2.tgz#4bb3d926da04a4a5f34420d17b5551fd6d9d535c" + integrity sha512-IE7hDSGV2U/VZoCsjctKX6l5t5ak2jE0+aeGJi3KtvjIUNuZVmHVYUjNBhmo369FIWGDtaieRaO8A83Lvwfpqw== + dependencies: + any-base "^1.1.0" + uuid "^8.3.2" + should-equal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" @@ -32722,7 +32874,7 @@ sucrase@^3.20.2: pirates "^4.0.1" ts-interface-checker "^0.1.9" -superagent@^8.0.5: +superagent@^8.0.5, superagent@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== @@ -32746,6 +32898,14 @@ supertest@6.3.3: methods "^1.1.2" superagent "^8.0.5" +supertest@^6.2.4: + version "6.3.4" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.4.tgz#2145c250570c2ea5d337db3552dbfb78a2286218" + integrity sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw== + dependencies: + methods "^1.1.2" + superagent "^8.1.2" + supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -33530,6 +33690,11 @@ ts-node@^10.9.1: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts-simple-type@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/ts-simple-type/-/ts-simple-type-1.0.7.tgz#03930af557528dd40eaa121913c7035a0baaacf8" + integrity sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ== + ts-toolbelt@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" @@ -33865,6 +34030,11 @@ typescript@5.2.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^3.8.3: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + typescript@^5.2.2: version "5.3.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" @@ -34750,6 +34920,26 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vscode-css-languageservice@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-4.3.0.tgz#40c797d664ab6188cace33cfbb19b037580a9318" + integrity sha512-BkQAMz4oVHjr0oOAz5PdeE72txlLQK7NIwzmclfr+b6fj6I8POwB+VoXvrZLTbWt9hWRgfvgiQRkh5JwrjPJ5A== + dependencies: + vscode-languageserver-textdocument "^1.0.1" + vscode-languageserver-types "3.16.0-next.2" + vscode-nls "^4.1.2" + vscode-uri "^2.1.2" + +vscode-html-languageservice@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-3.1.0.tgz#265b53bda595e6947b16b0fb8c604e1e58685393" + integrity sha512-QAyRHI98bbEIBCqTzZVA0VblGU40na0txggongw5ZgTj9UVsVk5XbLT16O9OTcbqBGSqn0oWmFDNjK/XGIDcqg== + dependencies: + vscode-languageserver-textdocument "^1.0.1" + vscode-languageserver-types "3.16.0-next.2" + vscode-nls "^4.1.2" + vscode-uri "^2.1.2" + vscode-json-languageservice@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/vscode-json-languageservice/-/vscode-json-languageservice-4.2.1.tgz#94b6f471ece193bf4a1ef37f6ab5cce86d50a8b4" @@ -34774,12 +34964,12 @@ vscode-languageserver-protocol@3.16.0: vscode-jsonrpc "6.0.0" vscode-languageserver-types "3.16.0" -vscode-languageserver-textdocument@^1.0.0, vscode-languageserver-textdocument@^1.0.3, vscode-languageserver-textdocument@^1.0.4: +vscode-languageserver-textdocument@^1.0.0, vscode-languageserver-textdocument@^1.0.1, vscode-languageserver-textdocument@^1.0.3, vscode-languageserver-textdocument@^1.0.4: version "1.0.11" resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz#0822a000e7d4dc083312580d7575fe9e3ba2e2bf" integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA== -vscode-languageserver-types@3.16.0, vscode-languageserver-types@3.17.1, vscode-languageserver-types@^3.0.0, vscode-languageserver-types@^3.16.0, vscode-languageserver-types@^3.17.1: +vscode-languageserver-types@3.16.0, vscode-languageserver-types@3.16.0-next.2, vscode-languageserver-types@3.17.1, vscode-languageserver-types@^3.0.0, vscode-languageserver-types@^3.16.0, vscode-languageserver-types@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.1.tgz#c2d87fa7784f8cac389deb3ff1e2d9a7bef07e16" integrity sha512-K3HqVRPElLZVVPtMeKlsyL9aK0GxGQpvtAUTfX4k7+iJ4mc1M+JM+zQwkgGy2LzY0f0IAafe8MKqIkJrxfGGjQ== @@ -34791,11 +34981,21 @@ vscode-languageserver@^7.0.0: dependencies: vscode-languageserver-protocol "3.16.0" +vscode-nls@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.2.tgz#ca8bf8bb82a0987b32801f9fddfdd2fb9fd3c167" + integrity sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw== + vscode-nls@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.2.0.tgz#3cb6893dd9bd695244d8a024bdf746eea665cc3f" integrity sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng== +vscode-uri@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-2.1.2.tgz#c8d40de93eb57af31f3c715dd650e2ca2c096f1c" + integrity sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A== + vscode-uri@^3.0.0, vscode-uri@^3.0.3: version "3.0.8" resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" @@ -34873,6 +35073,16 @@ wcwidth@>=1.0.1, wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-component-analyzer@~1.1.1: + version "1.1.7" + resolved "https://registry.yarnpkg.com/web-component-analyzer/-/web-component-analyzer-1.1.7.tgz#dad7f5a91f3b095c5a54f0f48d2dccb810c96b89" + integrity sha512-SqCqN4nU9fU+j0CKXJQ8E4cslLsaezhagY6xoi+hoNPPd55GzR6MY1r5jkoJUVu+g4Wy4uB+JglTt7au4vQ1uA== + dependencies: + fast-glob "^3.2.2" + ts-simple-type "~1.0.5" + typescript "^3.8.3" + yargs "^15.3.1" + web-encoding@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864" @@ -35176,6 +35386,11 @@ which-collection@^1.0.1: is-weakmap "^2.0.1" is-weakset "^2.0.1" +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + which-typed-array@^1.1.11, which-typed-array@^1.1.2, which-typed-array@^1.1.9: version "1.1.11" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a" @@ -35454,6 +35669,11 @@ xterm@^5.2.1, xterm@^5.3.0: resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.3.0.tgz#867daf9cc826f3d45b5377320aabd996cb0fce46" integrity sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -35494,6 +35714,14 @@ yaml@^2.0.0, yaml@^2.2.1, yaml@^2.2.2, yaml@^2.3.3: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.3.tgz#01f6d18ef036446340007db8e016810e5d64aad9" integrity sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^20.2.2, yargs-parser@^20.2.3, yargs-parser@^20.2.9: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" @@ -35504,6 +35732,23 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^16.0.0, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"