From 732835254e4f63ac21a4517f9936bf515904588c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 14 Feb 2024 14:50:31 -0800 Subject: [PATCH 01/34] Bootstrap FeedbackAsync with the original feedback code --- package.json | 1 + packages/feedback-async/.eslintignore | 2 + packages/feedback-async/.eslintrc.js | 36 ++ packages/feedback-async/.gitignore | 4 + packages/feedback-async/LICENSE | 14 + packages/feedback-async/README.md | 20 + packages/feedback-async/jest.config.js | 6 + packages/feedback-async/package.json | 62 +++ .../feedback-async/rollup.bundle.config.mjs | 13 + packages/feedback-async/rollup.npm.config.mjs | 16 + packages/feedback-async/src/constants.ts | 76 ++++ packages/feedback-async/src/debug-build.ts | 8 + packages/feedback-async/src/index.ts | 8 + packages/feedback-async/src/integration.ts | 396 ++++++++++++++++++ packages/feedback-async/src/sendFeedback.ts | 38 ++ packages/feedback-async/src/types/index.ts | 344 +++++++++++++++ .../src/util/handleFeedbackSubmit.ts | 42 ++ .../feedback-async/src/util/mergeOptions.ts | 22 + .../src/util/prepareFeedbackEvent.ts | 45 ++ .../src/util/sendFeedbackRequest.ts | 131 ++++++ .../src/util/setAttributesNS.ts | 9 + .../feedback-async/src/widget/Actor.css.ts | 54 +++ packages/feedback-async/src/widget/Actor.ts | 63 +++ .../feedback-async/src/widget/Dialog.css.ts | 200 +++++++++ packages/feedback-async/src/widget/Dialog.ts | 172 ++++++++ packages/feedback-async/src/widget/Form.ts | 250 +++++++++++ packages/feedback-async/src/widget/Icon.ts | 57 +++ packages/feedback-async/src/widget/Logo.ts | 59 +++ .../feedback-async/src/widget/Main.css.ts | 83 ++++ .../feedback-async/src/widget/SubmitButton.ts | 27 ++ .../feedback-async/src/widget/SuccessIcon.ts | 63 +++ .../src/widget/SuccessMessage.ts | 44 ++ .../src/widget/createShadowHost.ts | 36 ++ .../feedback-async/src/widget/createWidget.ts | 281 +++++++++++++ .../src/widget/util/createElement.ts | 55 +++ packages/feedback-async/test/index.ts | 0 .../feedback-async/test/integration.test.ts | 128 ++++++ .../feedback-async/test/sendFeedback.test.ts | 44 ++ .../unit/util/prepareFeedbackEvent.test.ts | 71 ++++ .../feedback-async/test/utils/TestClient.ts | 44 ++ packages/feedback-async/test/utils/mockSdk.ts | 58 +++ .../feedback-async/test/widget/Actor.test.ts | 42 ++ .../feedback-async/test/widget/Dialog.test.ts | 97 +++++ .../feedback-async/test/widget/Form.test.ts | 139 ++++++ .../test/widget/SubmitButton.test.ts | 22 + .../test/widget/SuccessMessage.test.ts | 32 ++ .../test/widget/createWidget.test.ts | 359 ++++++++++++++++ packages/feedback-async/tsconfig.json | 7 + packages/feedback-async/tsconfig.test.json | 15 + packages/feedback-async/tsconfig.types.json | 10 + 50 files changed, 3805 insertions(+) create mode 100644 packages/feedback-async/.eslintignore create mode 100644 packages/feedback-async/.eslintrc.js create mode 100644 packages/feedback-async/.gitignore create mode 100644 packages/feedback-async/LICENSE create mode 100644 packages/feedback-async/README.md create mode 100644 packages/feedback-async/jest.config.js create mode 100644 packages/feedback-async/package.json create mode 100644 packages/feedback-async/rollup.bundle.config.mjs create mode 100644 packages/feedback-async/rollup.npm.config.mjs create mode 100644 packages/feedback-async/src/constants.ts create mode 100644 packages/feedback-async/src/debug-build.ts create mode 100644 packages/feedback-async/src/index.ts create mode 100644 packages/feedback-async/src/integration.ts create mode 100644 packages/feedback-async/src/sendFeedback.ts create mode 100644 packages/feedback-async/src/types/index.ts create mode 100644 packages/feedback-async/src/util/handleFeedbackSubmit.ts create mode 100644 packages/feedback-async/src/util/mergeOptions.ts create mode 100644 packages/feedback-async/src/util/prepareFeedbackEvent.ts create mode 100644 packages/feedback-async/src/util/sendFeedbackRequest.ts create mode 100644 packages/feedback-async/src/util/setAttributesNS.ts create mode 100644 packages/feedback-async/src/widget/Actor.css.ts create mode 100644 packages/feedback-async/src/widget/Actor.ts create mode 100644 packages/feedback-async/src/widget/Dialog.css.ts create mode 100644 packages/feedback-async/src/widget/Dialog.ts create mode 100644 packages/feedback-async/src/widget/Form.ts create mode 100644 packages/feedback-async/src/widget/Icon.ts create mode 100644 packages/feedback-async/src/widget/Logo.ts create mode 100644 packages/feedback-async/src/widget/Main.css.ts create mode 100644 packages/feedback-async/src/widget/SubmitButton.ts create mode 100644 packages/feedback-async/src/widget/SuccessIcon.ts create mode 100644 packages/feedback-async/src/widget/SuccessMessage.ts create mode 100644 packages/feedback-async/src/widget/createShadowHost.ts create mode 100644 packages/feedback-async/src/widget/createWidget.ts create mode 100644 packages/feedback-async/src/widget/util/createElement.ts create mode 100644 packages/feedback-async/test/index.ts create mode 100644 packages/feedback-async/test/integration.test.ts create mode 100644 packages/feedback-async/test/sendFeedback.test.ts create mode 100644 packages/feedback-async/test/unit/util/prepareFeedbackEvent.test.ts create mode 100644 packages/feedback-async/test/utils/TestClient.ts create mode 100644 packages/feedback-async/test/utils/mockSdk.ts create mode 100644 packages/feedback-async/test/widget/Actor.test.ts create mode 100644 packages/feedback-async/test/widget/Dialog.test.ts create mode 100644 packages/feedback-async/test/widget/Form.test.ts create mode 100644 packages/feedback-async/test/widget/SubmitButton.test.ts create mode 100644 packages/feedback-async/test/widget/SuccessMessage.test.ts create mode 100644 packages/feedback-async/test/widget/createWidget.test.ts create mode 100644 packages/feedback-async/tsconfig.json create mode 100644 packages/feedback-async/tsconfig.test.json create mode 100644 packages/feedback-async/tsconfig.types.json diff --git a/package.json b/package.json index 0079352476fd..f78d59449577 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", "packages/feedback", + "packages/feedback-async", "packages/feedback-screenshot", "packages/gatsby", "packages/integrations", diff --git a/packages/feedback-async/.eslintignore b/packages/feedback-async/.eslintignore new file mode 100644 index 000000000000..b38db2f296ff --- /dev/null +++ b/packages/feedback-async/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/packages/feedback-async/.eslintrc.js b/packages/feedback-async/.eslintrc.js new file mode 100644 index 000000000000..cf9985e769c0 --- /dev/null +++ b/packages/feedback-async/.eslintrc.js @@ -0,0 +1,36 @@ +// Note: All paths are relative to the directory in which eslint is being run, rather than the directory where this file +// lives + +// ESLint config docs: https://eslint.org/docs/user-guide/configuring/ + +module.exports = { + extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['jest.setup.ts', 'jest.config.ts'], + parserOptions: { + project: ['tsconfig.test.json'], + }, + rules: { + 'no-console': 'off', + }, + }, + { + files: ['test/**/*.ts'], + + rules: { + // most of these errors come from `new Promise(process.nextTick)` + '@typescript-eslint/unbound-method': 'off', + // TODO: decide if we want to enable this again after the migration + // We can take the freedom to be a bit more lenient with tests + '@typescript-eslint/no-floating-promises': 'off', + }, + }, + { + files: ['src/types/deprecated.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + }, + }, + ], +}; diff --git a/packages/feedback-async/.gitignore b/packages/feedback-async/.gitignore new file mode 100644 index 000000000000..363d3467c6fa --- /dev/null +++ b/packages/feedback-async/.gitignore @@ -0,0 +1,4 @@ +node_modules +/*.tgz +.eslintcache +build diff --git a/packages/feedback-async/LICENSE b/packages/feedback-async/LICENSE new file mode 100644 index 000000000000..d11896ba1181 --- /dev/null +++ b/packages/feedback-async/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2023 Sentry (https://sentry.io) and individual contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/feedback-async/README.md b/packages/feedback-async/README.md new file mode 100644 index 000000000000..fb5b20400a71 --- /dev/null +++ b/packages/feedback-async/README.md @@ -0,0 +1,20 @@ +

+ + Sentry + +

+ +# Sentry Integration for Feedback + +This SDK is **considered experimental and in a beta state**. It may experience breaking changes, and may be discontinued at any time. Please reach out on +[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback/concerns. + +To view Feedback in Sentry, your [Sentry organization must be an early adopter](https://docs.sentry.io/product/accounts/early-adopter-features/). + +## Installation + +Please read the [offical integration documentation](https://docs.sentry.io/platforms/javascript/user-feedback/) for installation instructions. + +## Configuration + +The Feedback integration is highly customizable, please read the [official integration documentation](https://docs.sentry.io/platforms/javascript/user-feedback/configuration/) for the most up-to-date configuration options. diff --git a/packages/feedback-async/jest.config.js b/packages/feedback-async/jest.config.js new file mode 100644 index 000000000000..cd02790794a7 --- /dev/null +++ b/packages/feedback-async/jest.config.js @@ -0,0 +1,6 @@ +const baseConfig = require('../../jest/jest.config.js'); + +module.exports = { + ...baseConfig, + testEnvironment: 'jsdom', +}; diff --git a/packages/feedback-async/package.json b/packages/feedback-async/package.json new file mode 100644 index 000000000000..07ed4dbedf66 --- /dev/null +++ b/packages/feedback-async/package.json @@ -0,0 +1,62 @@ +{ + "name": "@sentry-internal/feedback-async", + "version": "7.100.0", + "description": "Sentry SDK integration for user feedback (async)", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback-async", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "files": [ + "cjs", + "esm", + "types", + "types-ts3.8" + ], + "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", + "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { + "build/npm/types/index.d.ts": [ + "build/npm/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/core": "7.100.0", + "@sentry/types": "7.100.0", + "@sentry/utils": "7.100.0" + }, + "scripts": { + "build": "run-p build:transpile build:types build:bundle", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:bundle": "rollup -c rollup.bundle.config.mjs", + "build:dev": "run-p build:transpile build:types", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", + "build:dev:watch": "run-p build:transpile:watch build:types:watch", + "build:transpile:watch": "yarn build:transpile --watch", + "build:bundle:watch": "yarn build:bundle --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "ts-node ../../scripts/prepack.ts --bundles && npm pack ./build/npm", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build sentry-feedback-*.tgz", + "fix": "eslint . --format stylish --fix", + "lint": "eslint . --format stylish", + "test": "jest", + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/feedback-async/rollup.bundle.config.mjs b/packages/feedback-async/rollup.bundle.config.mjs new file mode 100644 index 000000000000..522f0a1bebf5 --- /dev/null +++ b/packages/feedback-async/rollup.bundle.config.mjs @@ -0,0 +1,13 @@ +import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal/rollup-utils'; + +const baseBundleConfig = makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/index.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry-internal/feedback-async', + outputFileBase: () => 'bundles/feedback-async', +}); + +const builds = makeBundleConfigVariants(baseBundleConfig); + +export default builds; diff --git a/packages/feedback-async/rollup.npm.config.mjs b/packages/feedback-async/rollup.npm.config.mjs new file mode 100644 index 000000000000..5a1800f23b08 --- /dev/null +++ b/packages/feedback-async/rollup.npm.config.mjs @@ -0,0 +1,16 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + hasBundles: true, + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to false because for feedback we actually want + // to bundle everything into one file. + preserveModules: false, + }, + }, + }), +); diff --git a/packages/feedback-async/src/constants.ts b/packages/feedback-async/src/constants.ts new file mode 100644 index 000000000000..07782968375f --- /dev/null +++ b/packages/feedback-async/src/constants.ts @@ -0,0 +1,76 @@ +import { GLOBAL_OBJ } from '@sentry/utils'; + +// exporting a separate copy of `WINDOW` rather than exporting the one from `@sentry/browser` +// prevents the browser package from being bundled in the CDN bundle, and avoids a +// circular dependency between the browser and feedback packages +export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; + +const LIGHT_BACKGROUND = '#ffffff'; +const INHERIT = 'inherit'; +const SUBMIT_COLOR = 'rgba(108, 95, 199, 1)'; +const LIGHT_THEME = { + fontFamily: "system-ui, 'Helvetica Neue', Arial, sans-serif", + fontSize: '14px', + + background: LIGHT_BACKGROUND, + backgroundHover: '#f6f6f7', + foreground: '#2b2233', + border: '1.5px solid rgba(41, 35, 47, 0.13)', + borderRadius: '12px', + boxShadow: '0px 4px 24px 0px rgba(43, 34, 51, 0.12)', + + success: '#268d75', + error: '#df3338', + + submitBackground: 'rgba(88, 74, 192, 1)', + submitBackgroundHover: SUBMIT_COLOR, + submitBorder: SUBMIT_COLOR, + submitOutlineFocus: '#29232f', + submitForeground: LIGHT_BACKGROUND, + submitForegroundHover: LIGHT_BACKGROUND, + + cancelBackground: 'transparent', + cancelBackgroundHover: 'var(--background-hover)', + cancelBorder: 'var(--border)', + cancelOutlineFocus: 'var(--input-outline-focus)', + cancelForeground: 'var(--foreground)', + cancelForegroundHover: 'var(--foreground)', + + inputBackground: INHERIT, + inputForeground: INHERIT, + inputBorder: 'var(--border)', + inputOutlineFocus: SUBMIT_COLOR, + + formBorderRadius: '20px', + formContentBorderRadius: '6px', +}; + +export const DEFAULT_THEME = { + light: LIGHT_THEME, + dark: { + ...LIGHT_THEME, + + background: '#29232f', + backgroundHover: '#352f3b', + foreground: '#ebe6ef', + border: '1.5px solid rgba(235, 230, 239, 0.15)', + + success: '#2da98c', + error: '#f55459', + }, +}; + +export const ACTOR_LABEL = 'Report a Bug'; +export const CANCEL_BUTTON_LABEL = 'Cancel'; +export const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; +export const FORM_TITLE = 'Report a Bug'; +export const EMAIL_PLACEHOLDER = 'your.email@example.org'; +export const EMAIL_LABEL = 'Email'; +export const MESSAGE_PLACEHOLDER = "What's the bug? What did you expect?"; +export const MESSAGE_LABEL = 'Description'; +export const NAME_PLACEHOLDER = 'Your Name'; +export const NAME_LABEL = 'Name'; +export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; + +export const FEEDBACK_WIDGET_SOURCE = 'widget'; +export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback-async/src/debug-build.ts b/packages/feedback-async/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/feedback-async/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/feedback-async/src/index.ts b/packages/feedback-async/src/index.ts new file mode 100644 index 000000000000..7832b23d4547 --- /dev/null +++ b/packages/feedback-async/src/index.ts @@ -0,0 +1,8 @@ +export { sendFeedback } from './sendFeedback'; +export { + // eslint-disable-next-line deprecation/deprecation + FeedbackAsync, + feedbackAsyncIntegration, +} from './integration'; + +console.log('Feedback 4'); diff --git a/packages/feedback-async/src/integration.ts b/packages/feedback-async/src/integration.ts new file mode 100644 index 000000000000..0dee124c6672 --- /dev/null +++ b/packages/feedback-async/src/integration.ts @@ -0,0 +1,396 @@ +import type { Integration, IntegrationFn } from '@sentry/types'; +import { isBrowser, logger } from '@sentry/utils'; + +import { + ACTOR_LABEL, + CANCEL_BUTTON_LABEL, + DEFAULT_THEME, + EMAIL_LABEL, + EMAIL_PLACEHOLDER, + FORM_TITLE, + MESSAGE_LABEL, + MESSAGE_PLACEHOLDER, + NAME_LABEL, + NAME_PLACEHOLDER, + SUBMIT_BUTTON_LABEL, + SUCCESS_MESSAGE_TEXT, + WINDOW, +} from './constants'; +import { DEBUG_BUILD } from './debug-build'; +import type { FeedbackInternalOptions, FeedbackWidget, OptionalFeedbackConfiguration } from './types'; +import { mergeOptions } from './util/mergeOptions'; +import { createActorStyles } from './widget/Actor.css'; +import { createShadowHost } from './widget/createShadowHost'; +import { createWidget } from './widget/createWidget'; + +const doc = WINDOW.document; + +export const feedbackAsyncIntegration = ((options?: OptionalFeedbackConfiguration) => { + // eslint-disable-next-line deprecation/deprecation + return new FeedbackAsync(options); +}) satisfies IntegrationFn; + +/** + * Feedback integration. When added as an integration to the SDK, it will + * inject a button in the bottom-right corner of the window that opens a + * feedback modal when clicked. + * + * @deprecated Use `feedbackIntegration()` instead. + */ +export class FeedbackAsync implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Feedback'; + + /** + * @inheritDoc + */ + public name: string; + + /** + * Feedback configuration options + */ + public options: FeedbackInternalOptions; + + /** + * Reference to widget element that is created when autoInject is true + */ + private _widget: FeedbackWidget | null; + + /** + * List of all widgets that are created from the integration + */ + private _widgets: Set; + + /** + * Reference to the host element where widget is inserted + */ + private _host: HTMLDivElement | null; + + /** + * Refernce to Shadow DOM root + */ + private _shadow: ShadowRoot | null; + + /** + * Tracks if actor styles have ever been inserted into shadow DOM + */ + private _hasInsertedActorStyles: boolean; + + public constructor({ + id = 'sentry-feedback', + showBranding = true, + autoInject = true, + showEmail = true, + showName = true, + useSentryUser = { + email: 'email', + name: 'username', + }, + isEmailRequired = false, + isNameRequired = false, + + themeDark, + themeLight, + colorScheme = 'system', + + buttonLabel = ACTOR_LABEL, + cancelButtonLabel = CANCEL_BUTTON_LABEL, + submitButtonLabel = SUBMIT_BUTTON_LABEL, + formTitle = FORM_TITLE, + emailPlaceholder = EMAIL_PLACEHOLDER, + emailLabel = EMAIL_LABEL, + messagePlaceholder = MESSAGE_PLACEHOLDER, + messageLabel = MESSAGE_LABEL, + namePlaceholder = NAME_PLACEHOLDER, + nameLabel = NAME_LABEL, + successMessageText = SUCCESS_MESSAGE_TEXT, + + onFormClose, + onFormOpen, + onSubmitError, + onSubmitSuccess, + + // getScreenshotIntegration, + }: OptionalFeedbackConfiguration = {}) { + // eslint-disable-next-line deprecation/deprecation + this.name = FeedbackAsync.id; + + // tsc fails if these are not initialized explicitly constructor, e.g. can't call `_initialize()` + this._host = null; + this._shadow = null; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; + + this.options = { + id, + showBranding, + autoInject, + isEmailRequired, + isNameRequired, + showEmail, + showName, + useSentryUser, + + colorScheme, + themeDark: { + ...DEFAULT_THEME.dark, + ...themeDark, + }, + themeLight: { + ...DEFAULT_THEME.light, + ...themeLight, + }, + + buttonLabel, + cancelButtonLabel, + submitButtonLabel, + formTitle, + emailLabel, + emailPlaceholder, + messageLabel, + messagePlaceholder, + nameLabel, + namePlaceholder, + successMessageText, + + onFormClose, + onFormOpen, + onSubmitError, + onSubmitSuccess, + + // getScreenshotIntegration, + }; + } + + /** + * Setup and initialize feedback container + */ + public setupOnce(): void { + if (!isBrowser()) { + return; + } + + try { + this._cleanupWidgetIfExists(); + + const { autoInject } = this.options; + + if (!autoInject) { + // Nothing to do here + return; + } + + this._createWidget(this.options); + } catch (err) { + DEBUG_BUILD && logger.error(err); + console.log(err); + } + } + + /** + * Allows user to open the dialog box. Creates a new widget if + * `autoInject` was false, otherwise re-uses the default widget that was + * created during initialization of the integration. + */ + public openDialog(): void { + if (!this._widget) { + this._createWidget({ ...this.options, shouldCreateActor: false }); + } + + if (!this._widget) { + return; + } + + this._widget.openDialog(); + } + + /** + * Closes the dialog for the default widget, if it exists + */ + public closeDialog(): void { + if (!this._widget) { + // Nothing to do if widget does not exist + return; + } + + this._widget.closeDialog(); + } + + /** + * Adds click listener to attached element to open a feedback dialog + */ + public attachTo(el: Element | string, optionOverrides?: OptionalFeedbackConfiguration): FeedbackWidget | null { + try { + const options = mergeOptions(this.options, optionOverrides || {}); + + return this._ensureShadowHost(options, ({ shadow }) => { + const targetEl = + typeof el === 'string' ? doc.querySelector(el) : typeof el.addEventListener === 'function' ? el : null; + + if (!targetEl) { + DEBUG_BUILD && logger.error('[Feedback] Unable to attach to target element'); + return null; + } + + const widget = createWidget({ shadow, options, attachTo: targetEl }); + this._widgets.add(widget); + + if (!this._widget) { + this._widget = widget; + } + + return widget; + }); + } catch (err) { + DEBUG_BUILD && logger.error(err); + console.log(err); + return null; + } + } + + /** + * Creates a new widget. Accepts partial options to override any options passed to constructor. + */ + public createWidget( + optionOverrides?: OptionalFeedbackConfiguration & { shouldCreateActor?: boolean }, + ): FeedbackWidget | null { + try { + return this._createWidget(mergeOptions(this.options, optionOverrides || {})); + } catch (err) { + DEBUG_BUILD && logger.error(err); + console.log(err); + return null; + } + } + + /** + * Removes a single widget + */ + public removeWidget(widget: FeedbackWidget | null | undefined): boolean { + if (!widget) { + return false; + } + + try { + if (this._widgets.has(widget)) { + widget.removeActor(); + widget.removeDialog(); + this._widgets.delete(widget); + + if (this._widget === widget) { + // TODO: is more clean-up needed? e.g. call remove() + this._widget = null; + } + + return true; + } + } catch (err) { + console.log(err); + DEBUG_BUILD && logger.error(err); + } + + return false; + } + + /** + * Returns the default (first-created) widget + */ + public getWidget(): FeedbackWidget | null { + return this._widget; + } + + /** + * Removes the Feedback integration (including host, shadow DOM, and all widgets) + */ + public remove(): void { + if (this._host) { + this._host.remove(); + } + this._initialize(); + } + + /** + * Initializes values of protected properties + */ + protected _initialize(): void { + this._host = null; + this._shadow = null; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; + } + + /** + * Clean-up the widget if it already exists in the DOM. This shouldn't happen + * in prod, but can happen in development with hot module reloading. + */ + protected _cleanupWidgetIfExists(): void { + if (this._host) { + this.remove(); + } + const existingFeedback = doc.querySelector(`#${this.options.id}`); + if (existingFeedback) { + existingFeedback.remove(); + } + } + + /** + * Creates a new widget, after ensuring shadow DOM exists + */ + protected _createWidget(options: FeedbackInternalOptions & { shouldCreateActor?: boolean }): FeedbackWidget | null { + return this._ensureShadowHost(options, ({ shadow }) => { + const widget = createWidget({ shadow, options }); + + if (!this._hasInsertedActorStyles && widget.actor) { + shadow.appendChild(createActorStyles(doc)); + this._hasInsertedActorStyles = true; + } + + this._widgets.add(widget); + + if (!this._widget) { + this._widget = widget; + } + + return widget; + }); + } + + /** + * Ensures that shadow DOM exists and is added to the DOM + */ + protected _ensureShadowHost( + options: FeedbackInternalOptions, + cb: (createShadowHostResult: ReturnType) => T, + ): T | null { + let needsAppendHost = false; + + // Don't create if it already exists + if (!this._shadow || !this._host) { + const { id, colorScheme, themeLight, themeDark } = options; + const { shadow, host } = createShadowHost({ + id, + colorScheme, + themeLight, + themeDark, + }); + this._shadow = shadow; + this._host = host; + needsAppendHost = true; + } + + // set data attribute on host for different themes + this._host.dataset.sentryFeedbackColorscheme = options.colorScheme; + + const result = cb({ shadow: this._shadow, host: this._host }); + + if (needsAppendHost) { + doc.body.appendChild(this._host); + } + + return result; + } +} diff --git a/packages/feedback-async/src/sendFeedback.ts b/packages/feedback-async/src/sendFeedback.ts new file mode 100644 index 000000000000..ac60415d0462 --- /dev/null +++ b/packages/feedback-async/src/sendFeedback.ts @@ -0,0 +1,38 @@ +import { getLocationHref } from '@sentry/utils'; + +import { FEEDBACK_API_SOURCE } from './constants'; +import type { SendFeedbackOptions } from './types'; +import { sendFeedbackRequest } from './util/sendFeedbackRequest'; + +interface SendFeedbackParams { + message: string; + name?: string; + email?: string; + url?: string; + source?: string; +} + +/** + * Public API to send a Feedback item to Sentry + */ +export function sendFeedback( + { name, email, message, source = FEEDBACK_API_SOURCE, url = getLocationHref() }: SendFeedbackParams, + options: SendFeedbackOptions = {}, +): ReturnType { + if (!message) { + throw new Error('Unable to submit feedback with empty message'); + } + + return sendFeedbackRequest( + { + feedback: { + name, + email, + message, + url, + source, + }, + }, + options, + ); +} diff --git a/packages/feedback-async/src/types/index.ts b/packages/feedback-async/src/types/index.ts new file mode 100644 index 000000000000..f666f2aaa8ef --- /dev/null +++ b/packages/feedback-async/src/types/index.ts @@ -0,0 +1,344 @@ +import type { Primitive } from '@sentry/types'; + +import type { ActorComponent } from '../widget/Actor'; +import type { DialogComponent } from '../widget/Dialog'; + +export type SentryTags = { [key: string]: Primitive } | undefined; + +export interface SendFeedbackData { + feedback: { + message: string; + url: string; + email?: string; + replay_id?: string; + name?: string; + source?: string; + }; +} + +export interface SendFeedbackOptions { + /** + * Should include replay with the feedback? + */ + includeReplay?: boolean; +} + +/** + * Feedback data expected from UI/form + */ +export interface FeedbackFormData { + message: string; + email?: string; + name?: string; +} + +/** + * General feedback configuration + */ +export interface FeedbackGeneralConfiguration { + /** + * id to use for the main widget container (this will host the shadow DOM) + */ + id: string; + + /** + * Show the Sentry branding + */ + showBranding: boolean; + + /** + * Auto-inject default Feedback actor button to the DOM when integration is + * added. + */ + autoInject: boolean; + + /** + * Should the email field be required? + */ + isEmailRequired: boolean; + + /** + * Should the name field be required? + */ + isNameRequired: boolean; + + /** + * Should the email input field be visible? Note: email will still be collected if set via `Sentry.setUser()` + */ + showEmail: boolean; + + /** + * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()` + */ + showName: boolean; + + /** + * Fill in email/name input fields with Sentry user context if it exists. + * The value of the email/name keys represent the properties of your user context. + */ + useSentryUser: { + email: string; + name: string; + }; + + // getScreenshotIntegration: () => Integration; +} + +/** + * Theme-related configuration + */ +export interface FeedbackThemeConfiguration { + /** + * The colorscheme to use. "system" will choose the scheme based on the user's system settings + */ + colorScheme: 'system' | 'light' | 'dark'; + + /** + * Light theme customization, will be merged with default theme values. + */ + themeLight: FeedbackTheme; + /** + * Dark theme customization, will be merged with default theme values. + */ + themeDark: FeedbackTheme; +} + +/** + * All of the different text labels that can be customized + */ +export interface FeedbackTextConfiguration { + /** + * The label for the Feedback widget button that opens the dialog + */ + buttonLabel: string; + /** + * The label for the Feedback form cancel button that closes dialog + */ + cancelButtonLabel: string; + /** + * The label for the Feedback form submit button that sends feedback + */ + submitButtonLabel: string; + /** + * The title of the Feedback form + */ + formTitle: string; + /** + * Label for the email input + */ + emailLabel: string; + /** + * Placeholder text for Feedback email input + */ + emailPlaceholder: string; + /** + * Label for the message input + */ + messageLabel: string; + /** + * Placeholder text for Feedback message input + */ + messagePlaceholder: string; + /** + * Label for the name input + */ + nameLabel: string; + /** + * Placeholder text for Feedback name input + */ + namePlaceholder: string; + /** + * Message after feedback was sent successfully + */ + successMessageText: string; +} + +/** + * The public callbacks available for the feedback integration + */ +export interface FeedbackCallbacks { + /** + * Callback when form is closed + */ + onFormClose?: () => void; + + /** + * Callback when form is opened + */ + onFormOpen?: () => void; + + /** + * Callback when feedback is successfully submitted + */ + onSubmitSuccess?: () => void; + + /** + * Callback when feedback is unsuccessfully submitted + */ + onSubmitError?: () => void; +} + +/** + * The integration's internal `options` member where every value should be set + */ +export interface FeedbackInternalOptions + extends FeedbackGeneralConfiguration, + FeedbackThemeConfiguration, + FeedbackTextConfiguration, + FeedbackCallbacks {} + +/** + * Partial configuration that overrides default configuration values + */ +export interface OptionalFeedbackConfiguration + extends Omit, 'themeLight' | 'themeDark'> { + themeLight?: Partial; + themeDark?: Partial; +} + +export interface FeedbackTheme { + /** + * Font family for widget + */ + fontFamily: string; + /** + * Font size for widget + */ + fontSize: string; + /** + * Background color for actor and dialog + */ + background: string; + /** + * Background color on hover + */ + backgroundHover: string; + /** + * Border styling for actor and dialog + */ + border: string; + /** + * Border radius styling for actor + */ + borderRadius: string; + /** + * Box shadow for actor and dialog + */ + boxShadow: string; + /** + * Foreground color (i.e. text color) + */ + foreground: string; + /** + * Success color + */ + success: string; + /** + * Error color + */ + error: string; + + /** + * Background color for the submit button + */ + submitBackground: string; + /** + * Background color when hovering over the submit button + */ + submitBackgroundHover: string; + /** + * Border style for the submit button + */ + submitBorder: string; + /** + * Border style for the submit button, in the focued state + */ + submitOutlineFocus: string; + /** + * Foreground color for the submit button + */ + submitForeground: string; + + /** + * Foreground color for the submit button, in the hover state + */ + submitForegroundHover: string; + + /** + * Background color for the cancel button + */ + cancelBackground: string; + /** + * Background color when hovering over the cancel button + */ + cancelBackgroundHover: string; + /** + * Border style for the cancel button + */ + cancelBorder: string; + /** + * Border style for the cancel button, in the focued state + */ + cancelOutlineFocus: string; + /** + * Foreground color for the cancel button + */ + cancelForeground: string; + /** + * Foreground color for the cancel button, in the hover state + */ + cancelForegroundHover: string; + + /** + * Background color for form inputs + */ + inputBackground: string; + /** + * Foreground color for form inputs + */ + inputForeground: string; + /** + * Border styles for form inputs + */ + inputBorder: string; + /** + * Border styles for form inputs when focused + */ + inputOutlineFocus: string; + /** + * Border radius for dialog + */ + formBorderRadius: string; + /** + * Border radius for form inputs + */ + formContentBorderRadius: string; +} + +export interface FeedbackThemes { + dark: FeedbackTheme; + light: FeedbackTheme; +} + +export interface FeedbackComponent { + el: T | null; +} + +/** + * A widget consists of: + * - actor button [that opens dialog] + * - dialog + feedback form + * - shadow root? + */ +export interface FeedbackWidget { + actor: ActorComponent | undefined; + dialog: DialogComponent | undefined; + + showActor: () => void; + hideActor: () => void; + removeActor: () => void; + + openDialog: () => void; + closeDialog: () => void; + removeDialog: () => void; +} diff --git a/packages/feedback-async/src/util/handleFeedbackSubmit.ts b/packages/feedback-async/src/util/handleFeedbackSubmit.ts new file mode 100644 index 000000000000..abb3aeb1368d --- /dev/null +++ b/packages/feedback-async/src/util/handleFeedbackSubmit.ts @@ -0,0 +1,42 @@ +import type { TransportMakeRequestResponse } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { FEEDBACK_WIDGET_SOURCE } from '../constants'; +import { DEBUG_BUILD } from '../debug-build'; +import { sendFeedback } from '../sendFeedback'; +import type { FeedbackFormData, SendFeedbackOptions } from '../types'; +import type { DialogComponent } from '../widget/Dialog'; + +/** + * Handles UI behavior of dialog when feedback is submitted, calls + * `sendFeedback` to send feedback. + */ +export async function handleFeedbackSubmit( + dialog: DialogComponent | null, + feedback: FeedbackFormData, + options?: SendFeedbackOptions, +): Promise { + if (!dialog) { + // Not sure when this would happen + return; + } + + const showFetchError = (): void => { + if (!dialog) { + return; + } + dialog.showError('There was a problem submitting feedback, please wait and try again.'); + }; + + dialog.hideError(); + + try { + const resp = await sendFeedback({ ...feedback, source: FEEDBACK_WIDGET_SOURCE }, options); + + // Success! + return resp; + } catch (err) { + DEBUG_BUILD && logger.error(err); + showFetchError(); + } +} diff --git a/packages/feedback-async/src/util/mergeOptions.ts b/packages/feedback-async/src/util/mergeOptions.ts new file mode 100644 index 000000000000..bfaae524c7f6 --- /dev/null +++ b/packages/feedback-async/src/util/mergeOptions.ts @@ -0,0 +1,22 @@ +import type { FeedbackInternalOptions, OptionalFeedbackConfiguration } from '../types'; + +/** + * Quick and dirty deep merge for the Feedback integration options + */ +export function mergeOptions( + defaultOptions: FeedbackInternalOptions, + optionOverrides: OptionalFeedbackConfiguration, +): FeedbackInternalOptions { + return { + ...defaultOptions, + ...optionOverrides, + themeDark: { + ...defaultOptions.themeDark, + ...optionOverrides.themeDark, + }, + themeLight: { + ...defaultOptions.themeLight, + ...optionOverrides.themeLight, + }, + }; +} diff --git a/packages/feedback-async/src/util/prepareFeedbackEvent.ts b/packages/feedback-async/src/util/prepareFeedbackEvent.ts new file mode 100644 index 000000000000..cb48efeaf89d --- /dev/null +++ b/packages/feedback-async/src/util/prepareFeedbackEvent.ts @@ -0,0 +1,45 @@ +import type { Scope } from '@sentry/core'; +import { getIsolationScope } from '@sentry/core'; +import { prepareEvent } from '@sentry/core'; +import type { Client, FeedbackEvent } from '@sentry/types'; + +interface PrepareFeedbackEventParams { + client: Client; + event: FeedbackEvent; + scope: Scope; +} +/** + * Prepare a feedback event & enrich it with the SDK metadata. + */ +export async function prepareFeedbackEvent({ + client, + scope, + event, +}: PrepareFeedbackEventParams): Promise { + const eventHint = {}; + if (client.emit) { + client.emit('preprocessEvent', event, eventHint); + } + + const preparedEvent = (await prepareEvent( + client.getOptions(), + event, + eventHint, + scope, + client, + getIsolationScope(), + )) as FeedbackEvent | null; + + if (preparedEvent === null) { + // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions + client.recordDroppedEvent('event_processor', 'feedback', event); + return null; + } + + // This normally happens in browser client "_prepareEvent" + // but since we do not use this private method from the client, but rather the plain import + // we need to do this manually. + preparedEvent.platform = preparedEvent.platform || 'javascript'; + + return preparedEvent; +} diff --git a/packages/feedback-async/src/util/sendFeedbackRequest.ts b/packages/feedback-async/src/util/sendFeedbackRequest.ts new file mode 100644 index 000000000000..f1629a00670a --- /dev/null +++ b/packages/feedback-async/src/util/sendFeedbackRequest.ts @@ -0,0 +1,131 @@ +import { createEventEnvelope, getClient, withScope } from '@sentry/core'; +import type { FeedbackEvent, TransportMakeRequestResponse } from '@sentry/types'; + +import { FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE } from '../constants'; +import type { SendFeedbackData, SendFeedbackOptions } from '../types'; +import { prepareFeedbackEvent } from './prepareFeedbackEvent'; + +/** + * Send feedback using transport + */ +export async function sendFeedbackRequest( + { feedback: { message, email, name, source, url } }: SendFeedbackData, + { includeReplay = true }: SendFeedbackOptions = {}, +): Promise { + const client = getClient(); + const transport = client && client.getTransport(); + const dsn = client && client.getDsn(); + + if (!client || !transport || !dsn) { + return; + } + + const baseEvent: FeedbackEvent = { + contexts: { + feedback: { + contact_email: email, + name, + message, + url, + source, + }, + }, + type: 'feedback', + }; + + return withScope(async scope => { + // No use for breadcrumbs in feedback + scope.clearBreadcrumbs(); + + if ([FEEDBACK_API_SOURCE, FEEDBACK_WIDGET_SOURCE].includes(String(source))) { + scope.setLevel('info'); + } + + const feedbackEvent = await prepareFeedbackEvent({ + scope, + client, + event: baseEvent, + }); + + if (!feedbackEvent) { + return; + } + + if (client.emit) { + client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: Boolean(includeReplay) }); + } + + const envelope = createEventEnvelope(feedbackEvent, dsn, client.getOptions()._metadata, client.getOptions().tunnel); + + let response: void | TransportMakeRequestResponse; + + try { + response = await transport.send(envelope); + } catch (err) { + const error = new Error('Unable to send Feedback'); + + try { + // In case browsers don't allow this property to be writable + // @ts-expect-error This needs lib es2022 and newer + error.cause = err; + } catch { + // nothing to do + } + throw error; + } + + // TODO (v8): we can remove this guard once transport.send's type signature doesn't include void anymore + if (!response) { + return; + } + + // Require valid status codes, otherwise can assume feedback was not sent successfully + if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) { + throw new Error('Unable to send Feedback'); + } + + return response; + }); +} + +/* + * For reference, the fully built event looks something like this: + * { + * "type": "feedback", + * "event_id": "d2132d31b39445f1938d7e21b6bf0ec4", + * "timestamp": 1597977777.6189718, + * "dist": "1.12", + * "platform": "javascript", + * "environment": "production", + * "release": 42, + * "tags": {"transaction": "/organizations/:orgId/performance/:eventSlug/"}, + * "sdk": {"name": "name", "version": "version"}, + * "user": { + * "id": "123", + * "username": "user", + * "email": "user@site.com", + * "ip_address": "192.168.11.12", + * }, + * "request": { + * "url": None, + * "headers": { + * "user-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15" + * }, + * }, + * "contexts": { + * "feedback": { + * "message": "test message", + * "contact_email": "test@example.com", + * "type": "feedback", + * }, + * "trace": { + * "trace_id": "4C79F60C11214EB38604F4AE0781BFB2", + * "span_id": "FA90FDEAD5F74052", + * "type": "trace", + * }, + * "replay": { + * "replay_id": "e2d42047b1c5431c8cba85ee2a8ab25d", + * }, + * }, + * } + */ diff --git a/packages/feedback-async/src/util/setAttributesNS.ts b/packages/feedback-async/src/util/setAttributesNS.ts new file mode 100644 index 000000000000..74f0889a1f83 --- /dev/null +++ b/packages/feedback-async/src/util/setAttributesNS.ts @@ -0,0 +1,9 @@ +/** + * Helper function to set a dict of attributes on element (w/ specified namespace) + */ +export function setAttributesNS(el: T, attributes: Record): T { + Object.entries(attributes).forEach(([key, val]) => { + el.setAttributeNS(null, key, val); + }); + return el; +} diff --git a/packages/feedback-async/src/widget/Actor.css.ts b/packages/feedback-async/src/widget/Actor.css.ts new file mode 100644 index 000000000000..44bd60a3418e --- /dev/null +++ b/packages/feedback-async/src/widget/Actor.css.ts @@ -0,0 +1,54 @@ +/** + * Creates