diff --git a/packages/feedback/package.json b/packages/feedback/package.json index e56a2aa09613..e2b5041acbb2 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "7.75.1", + "version": "0.0.1-alpha.4", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -23,10 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.70.0", - "@sentry/core": "7.70.0", - "@sentry/types": "7.70.0", - "@sentry/utils": "7.70.0" + "@sentry/browser": "7.75.1", + "@sentry/core": "7.75.1", + "@sentry/types": "7.75.1", + "@sentry/utils": "7.75.1" }, "scripts": { "build": "run-p build:transpile build:types build:bundle", diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts index c5ed960a37cc..9075f45d377b 100644 --- a/packages/feedback/src/integration.ts +++ b/packages/feedback/src/integration.ts @@ -1,7 +1,6 @@ -import { getCurrentHub } from '@sentry/core'; +import { WINDOW } from '@sentry/browser'; import type { Integration } from '@sentry/types'; -import { isBrowser } from '@sentry/utils'; -import { logger } from '@sentry/utils'; +import { isBrowser, logger } from '@sentry/utils'; import { ACTOR_LABEL, @@ -17,21 +16,14 @@ import { SUBMIT_BUTTON_LABEL, SUCCESS_MESSAGE_TEXT, } from './constants'; -import type { FeedbackConfigurationWithDefaults, FeedbackFormData, FeedbackTheme } from './types'; -import { handleFeedbackSubmit } from './util/handleFeedbackSubmit'; -import { Actor } from './widget/Actor'; +import type { CreateWidgetOptionOverrides, FeedbackConfigurationWithDefaults, Widget } from './types'; import { createActorStyles } from './widget/Actor.css'; -import { Dialog } from './widget/Dialog'; -import { createDialogStyles } from './widget/Dialog.css'; -import { createMainStyles } from './widget/Main.css'; -import { SuccessMessage } from './widget/SuccessMessage'; - -interface FeedbackConfiguration extends Partial> { - theme?: { - dark?: Partial; - light?: Partial; - }; -} +import { createShadowHost } from './widget/createShadowHost'; +import { createWidget } from './widget/createWidget'; + +const doc = WINDOW.document; + +type FeedbackConfiguration = Partial; /** * Feedback integration. When added as an integration to the SDK, it will @@ -55,34 +47,32 @@ export class Feedback implements Integration { public options: FeedbackConfigurationWithDefaults; /** - * Reference to widget actor element (button that opens dialog). + * Reference to widget element that is created when autoInject is true */ - private _actor: ReturnType | null; + private _widget: Widget | null; + /** - * Reference to dialog element + * List of all widgets that are created from the integration */ - private _dialog: ReturnType | null; + 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; - /** - * State property to track if dialog is currently open - */ - private _isDialogOpen: boolean; /** - * Tracks if dialog has ever been opened at least one time + * Tracks if actor styles have ever been inserted into shadow DOM */ - private _hasDialogEverOpened: boolean; + private _hasInsertedActorStyles: boolean; public constructor({ id = 'sentry-feedback', - attachTo = null, autoInject = true, showEmail = true, showName = true, @@ -94,7 +84,8 @@ export class Feedback implements Integration { isEmailRequired = false, isNameRequired = false, - theme, + themeDark, + themeLight, colorScheme = 'system', buttonLabel = ACTOR_LABEL, @@ -110,22 +101,23 @@ export class Feedback implements Integration { successMessageText = SUCCESS_MESSAGE_TEXT, onActorClick, + onDialogClose, onDialogOpen, onSubmitError, onSubmitSuccess, }: FeedbackConfiguration = {}) { // Initializations this.name = Feedback.id; - this._actor = null; - this._dialog = null; + + // tsc fails if these are not initialized explicitly constructor, e.g. can't call `_initialize()` this._host = null; this._shadow = null; - this._isDialogOpen = false; - this._hasDialogEverOpened = false; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; this.options = { id, - attachTo, autoInject, isAnonymous, isEmailRequired, @@ -135,10 +127,8 @@ export class Feedback implements Integration { useSentryUser, colorScheme, - theme: { - dark: Object.assign({}, DEFAULT_THEME.dark, theme && theme.dark), - light: Object.assign({}, DEFAULT_THEME.light, theme && theme.light), - }, + themeDark: Object.assign({}, DEFAULT_THEME.dark, themeDark), + themeLight: Object.assign({}, DEFAULT_THEME.light, themeLight), buttonLabel, cancelButtonLabel, @@ -153,16 +143,13 @@ export class Feedback implements Integration { successMessageText, onActorClick, + onDialogClose, onDialogOpen, onSubmitError, onSubmitSuccess, }; - - // TOOD: temp for testing; - this.setupOnce(); } - /** If replay has already been initialized */ /** * Setup and initialize replay container */ @@ -176,272 +163,154 @@ export class Feedback implements Integration { if (this._host) { this.remove(); } - const existingFeedback = document.querySelector(`#${this.options.id}`); + const existingFeedback = doc.querySelector(`#${this.options.id}`); if (existingFeedback) { existingFeedback.remove(); } // TODO: End hotloading - const { attachTo, autoInject } = this.options; - if (!attachTo && !autoInject) { - // Nothing to do here - return; - } - - // Setup host element + shadow DOM, if necessary - this._shadow = this._createShadowHost(); - - // If `attachTo` is defined, then attach click handler to it - if (attachTo) { - const actorTarget = - typeof attachTo === 'string' - ? document.querySelector(attachTo) - : typeof attachTo === 'function' - ? attachTo - : null; - - if (!actorTarget) { - logger.warn(`[Feedback] Unable to find element with selector ${actorTarget}`); - return; - } - - actorTarget.addEventListener('click', this._handleActorClick); - } else { - this._createWidgetActor(); - } + const { autoInject } = this.options; - if (!this._host) { - logger.warn('[Feedback] Unable to create host element'); + if (!autoInject) { + // Nothing to do here return; } - document.body.appendChild(this._host); + this._widget = this._createWidget(this.options); } catch (err) { - // TODO: error handling - console.error(err); - } - } - - /** - * Removes the Feedback widget - */ - public remove(): void { - if (this._host) { - this._host.remove(); + logger.error(err); } } /** - * Opens the Feedback dialog form + * Adds click listener to attached element to open a feedback dialog */ - public openDialog(): void { + public attachTo(el: Node | string, optionOverrides: CreateWidgetOptionOverrides): Widget | null { try { - if (this._dialog) { - this._dialog.open(); - this._isDialogOpen = true; - if (this.options.onDialogOpen) { - this.options.onDialogOpen(); - } - return; - } + const options = Object.assign({}, this.options, optionOverrides); - try { - this._shadow = this._createShadowHost(); - } catch { - return; - } + return this._ensureShadowHost(options, ([shadow]) => { + const targetEl = + typeof el === 'string' ? doc.querySelector(el) : typeof el.addEventListener === 'function' ? el : null; - // Lazy-load until dialog is opened and only inject styles once - if (!this._hasDialogEverOpened) { - this._shadow.appendChild(createDialogStyles(document)); - } + if (!targetEl) { + logger.error('[Feedback] Unable to attach to target element'); + return null; + } - const userKey = this.options.useSentryUser; - const scope = getCurrentHub().getScope(); - const user = scope.getUser(); - - this._dialog = Dialog({ - defaultName: (userKey && user && user[userKey.name]) || '', - defaultEmail: (userKey && user && user[userKey.email]) || '', - onClosed: () => { - this.showActor(); - this._isDialogOpen = false; - }, - onCancel: () => { - this.hideDialog(); - this.showActor(); - }, - onSubmit: this._handleFeedbackSubmit, - options: this.options, + const widget = createWidget({ shadow, options, attachTo: targetEl }); + this._widgets.add(widget); + return widget; }); - this._shadow.appendChild(this._dialog.$el); - - // Hides the default actor whenever dialog is opened - this._actor && this._actor.hide(); - - this._hasDialogEverOpened = true; - if (this.options.onDialogOpen) { - this.options.onDialogOpen(); - } } catch (err) { - // TODO: Error handling? - console.error(err); + logger.error(err); + return null; } } /** - * Hides the dialog + * Creates a new widget. Accepts partial options to override any options passed to constructor. */ - public hideDialog = (): void => { - if (this._dialog) { - this._dialog.close(); - this._isDialogOpen = false; + public createWidget(optionOverrides: CreateWidgetOptionOverrides): Widget | null { + try { + return this._createWidget(Object.assign({}, this.options, optionOverrides)); + } catch (err) { + logger.error(err); + return null; } - }; + } /** - * Removes the dialog element from DOM + * Removes a single widget */ - public removeDialog = (): void => { - if (this._dialog) { - this._dialog.$el.remove(); - this._dialog = null; + public removeWidget(widget: Widget | null | undefined): boolean { + if (!widget) { + return false; } - }; - /** - * Displays the default actor - */ - public showActor = (): void => { - // TODO: Only show default actor - if (this._actor) { - this._actor.show(); + try { + if (this._widgets.has(widget)) { + widget.removeActor(); + widget.removeDialog(); + this._widgets.delete(widget); + return true; + } + } catch (err) { + logger.error(err); } - }; + return false; + } /** - * Creates the host element of widget's shadow DOM. Returns null if not supported. + * Removes the Feedback integration (including host, shadow DOM, and all widgets) */ - protected _createShadowHost(): ShadowRoot { - if (!document.head.attachShadow) { - // Shadow DOM not supported - logger.warn('[Feedback] Browser does not support shadow DOM API'); - throw new Error('Browser does not support shadow DOM API.'); - } - - // Don't create if it already exists - if (this._shadow) { - return this._shadow; + public remove(): void { + if (this._host) { + this._host.remove(); } - - // Create the host - this._host = document.createElement('div'); - this._host.id = this.options.id; - - // Create the shadow root - const shadow = this._host.attachShadow({ mode: 'open' }); - - shadow.appendChild(createMainStyles(document, this.options.colorScheme, this.options.theme)); - - return shadow; + this._initialize(); } /** - * Creates the host element of our shadow DOM as well as the actor + * Initializes values of protected properties */ - protected _createWidgetActor(): void { - if (!this._shadow) { - // This shouldn't happen... we could call `_createShadowHost` if this is the case? - return; - } - - try { - this._shadow.appendChild(createActorStyles(document)); - - // Create Actor component - this._actor = Actor({ options: this.options, onClick: this._handleActorClick }); - - this._shadow.appendChild(this._actor.$el); - } catch (err) { - // TODO: error handling - console.error(err); - } + protected _initialize(): void { + this._host = null; + this._shadow = null; + this._widget = null; + this._widgets = new Set(); + this._hasInsertedActorStyles = false; } /** - * Show the success message for 5 seconds + * Creates a new widget, after ensuring shadow DOM exists */ - protected _showSuccessMessage(): void { - if (!this._shadow) { - return; - } - - try { - const success = SuccessMessage({ - message: this.options.successMessageText, - onRemove: () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - this.showActor(); - }, - }); + protected _createWidget(options: FeedbackConfigurationWithDefaults): Widget | null { + return this._ensureShadowHost(options, ([shadow]) => { + const widget = createWidget({ shadow, options }); - this._shadow.appendChild(success.$el); + if (!this._hasInsertedActorStyles && widget.actor) { + shadow.appendChild(createActorStyles(doc)); + this._hasInsertedActorStyles = true; + } - const timeoutId = setTimeout(() => { - if (success) { - success.remove(); - } - }, 5000); - } catch (err) { - // TODO: error handling - console.error(err); - } + this._widgets.add(widget); + return widget; + }); } /** - * Handles when the actor is clicked, opens the dialog modal and calls any - * callbacks. + * Ensures that shadow DOM exists and is added to the DOM */ - protected _handleActorClick = (): void => { - // Open dialog - if (!this._isDialogOpen) { - this.openDialog(); - } + protected _ensureShadowHost( + options: FeedbackConfigurationWithDefaults, + cb: (createShadowHostResult: ReturnType) => T, + ): T | null { + let needsAppendHost = false; - // Hide actor button - if (this._actor) { - this._actor.hide(); + // Don't create if it already exists + if (!this._shadow && !this._host) { + const [shadow, host] = createShadowHost({ options }); + this._shadow = shadow; + this._host = host; + needsAppendHost = true; } - if (this.options.onActorClick) { - this.options.onActorClick(); + if (!this._shadow || !this._host) { + logger.warn('[Feedback] Unable to create host element and/or shadow DOM'); + // This shouldn't happen + return null; } - }; - /** - * Handler for when the feedback form is completed by the user. This will - * create and send the feedback message as an event. - */ - protected _handleFeedbackSubmit = async (feedback: FeedbackFormData): Promise => { - const result = await handleFeedbackSubmit(this._dialog, feedback); + // set data attribute on host for different themes + this._host.dataset.sentryFeedbackColorscheme = options.colorScheme; - // Error submitting feedback - if (!result) { - if (this.options.onSubmitError) { - this.options.onSubmitError(); - } + const result = cb([this._shadow, this._host]); - return; + if (needsAppendHost) { + doc.body.appendChild(this._host); } - // Success - this.removeDialog(); - this._showSuccessMessage(); - - if (this.options.onSubmitSuccess) { - this.options.onSubmitSuccess(); - } - }; + return result; + } } diff --git a/packages/feedback/src/sendFeedback.ts b/packages/feedback/src/sendFeedback.ts index 626f4baba735..133fcbcc7b92 100644 --- a/packages/feedback/src/sendFeedback.ts +++ b/packages/feedback/src/sendFeedback.ts @@ -1,7 +1,9 @@ import type { BrowserClient, Replay } from '@sentry/browser'; import { getCurrentHub } from '@sentry/core'; +import { getLocationHref } from '@sentry/utils'; import { sendFeedbackRequest } from './util/sendFeedbackRequest'; +import { SendFeedbackOptions } from './types'; interface SendFeedbackParams { message: string; @@ -10,16 +12,12 @@ interface SendFeedbackParams { url?: string; } -interface SendFeedbackOptions { - includeReplay?: boolean; -} - /** * Public API to send a Feedback item to Sentry */ export function sendFeedback( - { name, email, message, url = document.location.href }: SendFeedbackParams, - { includeReplay = true }: SendFeedbackOptions = {}, + { name, email, message, url = getLocationHref() }: SendFeedbackParams, + { referrer, includeReplay = true }: SendFeedbackOptions = {}, ): ReturnType { const hub = getCurrentHub(); const client = hub && hub.getClient(); @@ -37,5 +35,6 @@ export function sendFeedback( url, replay_id: replayId, }, + referrer, }); } diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 267fa3465d2a..a1cc9686c324 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -1,5 +1,8 @@ import type { Event, Primitive } from '@sentry/types'; +import type { ActorComponent } from '../widget/Actor'; +import type { DialogComponent } from '../widget/Dialog'; + export type SentryTags = { [key: string]: Primitive } | undefined; /** @@ -26,8 +29,24 @@ export interface SendFeedbackData { replay_id?: string; name?: string; }; + referrer?: string; +} + +export interface SendFeedbackOptions { + /** + * Should include replay with the feedback? + */ + includeReplay?: boolean; + + /** + * Allows user to set a referrer for feedback, to act as a category for the feedback + */ + referrer?: string; } +/** + * Feedback data expected from UI/form + */ export interface FeedbackFormData { message: string; email?: string; @@ -40,11 +59,6 @@ export interface FeedbackConfigurationWithDefaults { */ id: string; - /** - * DOM Selector to attach click listener to, for opening Feedback dialog. - */ - attachTo: Node | string | null; - /** * Auto-inject default Feedback actor button to the DOM when integration is * added. @@ -92,12 +106,13 @@ export interface FeedbackConfigurationWithDefaults { colorScheme: 'system' | 'light' | 'dark'; /** - * Theme customization, will be merged with default theme values. + * Light theme customization, will be merged with default theme values. */ - theme: { - dark: FeedbackTheme; - light: FeedbackTheme; - }; + themeLight: FeedbackTheme; + /** + * Dark theme customization, will be merged with default theme values. + */ + themeDark: FeedbackTheme; // * End of Color theme customization * // // * Text customization * // @@ -148,6 +163,11 @@ export interface FeedbackConfigurationWithDefaults { // * End of text customization * // // * Start of Callbacks * // + /** + * Callback when dialog is closed + */ + onDialogClose?: () => void; + /** * Callback when dialog is opened */ @@ -209,6 +229,10 @@ export interface FeedbackTheme { error: string; } +export interface CreateWidgetOptionOverrides extends Partial { + referrer?: string; +} + export interface FeedbackThemes { dark: FeedbackTheme; light: FeedbackTheme; @@ -217,3 +241,22 @@ export interface FeedbackThemes { export interface FeedbackComponent { $el: T; } + +/** + * A widget consists of: + * - actor button [that opens dialog] + * - dialog + feedback form + * - shadow root? + */ +export interface Widget { + actor: ActorComponent | undefined; + dialog: DialogComponent | undefined; + + showActor: () => void; + hideActor: () => void; + removeActor: () => void; + + openDialog: () => void; + hideDialog: () => void; + removeDialog: () => void; +} diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts index c109c0c9214e..16efd1e568c3 100644 --- a/packages/feedback/src/util/handleFeedbackSubmit.ts +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -1,13 +1,14 @@ import { sendFeedback } from '../sendFeedback'; -import type { FeedbackFormData } from '../types'; +import type { FeedbackFormData, SendFeedbackOptions } from '../types'; import type { DialogComponent } from '../widget/Dialog'; /** - * + * Calls `sendFeedback` to send feedback, handles UI behavior of dialog. */ export async function handleFeedbackSubmit( dialog: DialogComponent | null, feedback: FeedbackFormData, + options?: SendFeedbackOptions ): Promise { if (!dialog) { // Not sure when this would happen @@ -25,7 +26,7 @@ export async function handleFeedbackSubmit( try { dialog.hideError(); dialog.setSubmitDisabled(); - const resp = await sendFeedback(feedback); + const resp = await sendFeedback(feedback, options); if (!resp) { // Errored... re-enable submit button diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts index 626457d6122b..60d99066d250 100644 --- a/packages/feedback/src/util/sendFeedbackRequest.ts +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -9,6 +9,7 @@ import { prepareFeedbackEvent } from './prepareFeedbackEvent'; */ export async function sendFeedbackRequest({ feedback: { message, email, name, replay_id, url }, + referrer, }: SendFeedbackData): Promise { const hub = getCurrentHub(); @@ -33,6 +34,9 @@ export async function sendFeedbackRequest({ replay_id, url, }, + tags: { + referrer, + } // type: 'feedback_event', }; diff --git a/packages/feedback/src/widget/Actor.ts b/packages/feedback/src/widget/Actor.ts index 5610905ed3ee..ddd2081a3732 100644 --- a/packages/feedback/src/widget/Actor.ts +++ b/packages/feedback/src/widget/Actor.ts @@ -7,7 +7,7 @@ interface Props { onClick?: (e: MouseEvent) => void; } -interface ActorComponent extends FeedbackComponent { +export interface ActorComponent extends FeedbackComponent { /** * Shows the actor element */ diff --git a/packages/feedback/src/widget/Dialog.ts b/packages/feedback/src/widget/Dialog.ts index 5e37a74239f6..135d31ae2e17 100644 --- a/packages/feedback/src/widget/Dialog.ts +++ b/packages/feedback/src/widget/Dialog.ts @@ -41,6 +41,11 @@ export interface DialogComponent extends FeedbackComponent { * Closes the dialog and form */ close: () => void; + + /** + * Check if dialog is currently opened + */ + checkIsOpen: () => boolean; } /** @@ -87,6 +92,13 @@ export function Dialog({ } } + /** + * Check if dialog is currently opened + */ + function checkIsOpen(): boolean { + return ($el && $el.open === true) || false; + } + const { $el: $form, setSubmitEnabled, @@ -104,7 +116,6 @@ export function Dialog({ $el = h( 'dialog', { - id: 'feedback-dialog', className: 'dialog', open: true, onClick: handleDialogClick, @@ -131,5 +142,6 @@ export function Dialog({ setSubmitEnabled, open, close, + checkIsOpen, }; } diff --git a/packages/feedback/src/widget/Icon.ts b/packages/feedback/src/widget/Icon.ts index e0564dd92b69..5b50715d69df 100644 --- a/packages/feedback/src/widget/Icon.ts +++ b/packages/feedback/src/widget/Icon.ts @@ -1,3 +1,4 @@ +import { WINDOW } from '@sentry/browser'; import { setAttributesNS } from '../util/setAttributesNS'; const SIZE = 20; @@ -11,7 +12,8 @@ interface IconReturn { * Feedback Icon */ export function Icon(): IconReturn { - const cENS = document.createElementNS.bind(document, XMLNS); + const cENS = (tagName: K): SVGElementTagNameMap[K] => + WINDOW.document.createElementNS(XMLNS, tagName); const svg = setAttributesNS(cENS('svg'), { class: 'feedback-icon', width: `${SIZE}`, diff --git a/packages/feedback/src/widget/Main.css.ts b/packages/feedback/src/widget/Main.css.ts index 9932fdcfa771..e434739dcecd 100644 --- a/packages/feedback/src/widget/Main.css.ts +++ b/packages/feedback/src/widget/Main.css.ts @@ -9,30 +9,35 @@ export function createMainStyles( themes: FeedbackThemes, ): HTMLStyleElement { const style = d.createElement('style'); - const theme = colorScheme === 'system' ? themes.light : themes[colorScheme]; style.textContent = ` :host { --bottom: 1rem; --right: 1rem; --top: auto; --left: auto; + --z-index: 100000; + --font-family: ${themes.light.fontFamily}; + --font-size: ${themes.light.fontSize}; position: fixed; left: var(--left); right: var(--right); bottom: var(--bottom); top: var(--top); + z-index: var(--z-index); - font-family: ${theme.fontFamily}; - font-size: ${theme.fontSize}; - --bg-color: ${theme.background}; - --bg-hover-color: ${theme.backgroundHover}; - --fg-color: ${theme.foreground}; - --error-color: ${theme.error}; - --success-color: ${theme.success}; - --border: ${theme.border}; - --box-shadow: ${theme.boxShadow}; + font-family: var(--font-family); + font-size: var(--font-size); + + --bg-color: ${themes.light.background}; + --bg-hover-color: ${themes.light.backgroundHover}; + --fg-color: ${themes.light.foreground}; + --error-color: ${themes.light.error}; + --success-color: ${themes.light.success}; + --border: ${themes.light.border}; + --box-shadow: ${themes.light.boxShadow}; } + ${ colorScheme === 'system' ? ` @@ -45,10 +50,24 @@ ${ --success-color: ${themes.dark.success}; --border: ${themes.dark.border}; --box-shadow: ${themes.dark.boxShadow}; + --font-family: ${themes.dark.fontFamily}; + --font-size: ${themes.dark.fontSize}; } } ` - : '' + : ` +:host-context([data-sentry-feedback-colorscheme="dark"]) { + --bg-color: ${themes.dark.background}; + --bg-hover-color: ${themes.dark.backgroundHover}; + --fg-color: ${themes.dark.foreground}; + --error-color: ${themes.dark.error}; + --success-color: ${themes.dark.success}; + --border: ${themes.dark.border}; + --box-shadow: ${themes.dark.boxShadow}; + --font-family: ${themes.dark.fontFamily}; + --font-size: ${themes.dark.fontSize}; +} +` }`; return style; diff --git a/packages/feedback/src/widget/SuccessIcon.ts b/packages/feedback/src/widget/SuccessIcon.ts index bd60d8306271..3e2cda5e135b 100644 --- a/packages/feedback/src/widget/SuccessIcon.ts +++ b/packages/feedback/src/widget/SuccessIcon.ts @@ -1,3 +1,4 @@ +import { WINDOW } from '@sentry/browser'; import { setAttributesNS } from '../util/setAttributesNS'; const WIDTH = 16; @@ -12,7 +13,8 @@ interface IconReturn { * Success Icon (checkmark) */ export function SuccessIcon(): IconReturn { - const cENS = document.createElementNS.bind(document, XMLNS); + const cENS = (tagName: K): SVGElementTagNameMap[K] => + WINDOW.document.createElementNS(XMLNS, tagName); const svg = setAttributesNS(cENS('svg'), { class: 'success-icon', width: `${WIDTH}`, diff --git a/packages/feedback/src/widget/createShadowHost.ts b/packages/feedback/src/widget/createShadowHost.ts new file mode 100644 index 000000000000..66428299cee9 --- /dev/null +++ b/packages/feedback/src/widget/createShadowHost.ts @@ -0,0 +1,37 @@ +import { WINDOW } from '@sentry/browser'; +import { logger } from '@sentry/utils'; + +import type { FeedbackConfigurationWithDefaults } from '../types'; +import { createDialogStyles } from './Dialog.css'; +import { createMainStyles } from './Main.css'; + +interface CreateShadowHostParams { + options: FeedbackConfigurationWithDefaults; +} + +/** + * Creates shadow host + */ +export function createShadowHost({ options }: CreateShadowHostParams): [shadow: ShadowRoot, host: HTMLDivElement] { + const doc = WINDOW.document; + + if (!doc.head.attachShadow) { + // Shadow DOM not supported + logger.warn('[Feedback] Browser does not support shadow DOM API'); + throw new Error('Browser does not support shadow DOM API.'); + } + + // Create the host + const host = doc.createElement('div'); + host.id = options.id; + + // Create the shadow root + const shadow = host.attachShadow({ mode: 'open' }); + + shadow.appendChild( + createMainStyles(doc, options.colorScheme, { dark: options.themeDark, light: options.themeLight }), + ); + shadow.appendChild(createDialogStyles(doc)); + + return [shadow, host]; +} diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts new file mode 100644 index 000000000000..6246dd27ff6f --- /dev/null +++ b/packages/feedback/src/widget/createWidget.ts @@ -0,0 +1,216 @@ +import { getCurrentHub } from '@sentry/core'; +import { logger } from '@sentry/utils'; + +import type { FeedbackConfigurationWithDefaults, FeedbackFormData, Widget } from '../types'; +import { handleFeedbackSubmit } from '../util/handleFeedbackSubmit'; +import type { ActorComponent } from './Actor'; +import { Actor } from './Actor'; +import type { DialogComponent } from './Dialog'; +import { Dialog } from './Dialog'; +import { SuccessMessage } from './SuccessMessage'; + +interface CreateWidgetParams { + shadow: ShadowRoot; + options: FeedbackConfigurationWithDefaults & {referrer?: string}; + attachTo?: Node; +} + +/** + * Creates a new widget. Returns public methods that control widget behavior. + */ +export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): Widget { + let actor: ActorComponent | undefined; + let dialog: DialogComponent | undefined; + let isDialogOpen: boolean = false; + + /** + * Show the success message for 5 seconds + */ + function showSuccessMessage(): void { + if (!shadow) { + return; + } + + try { + const success = SuccessMessage({ + message: options.successMessageText, + onRemove: () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + showActor(); + }, + }); + + shadow.appendChild(success.$el); + + const timeoutId = setTimeout(() => { + if (success) { + success.remove(); + } + }, 5000); + } catch (err) { + // TODO: error handling + logger.error(err); + } + } + + /** + * Handler for when the feedback form is completed by the user. This will + * create and send the feedback message as an event. + */ + async function _handleFeedbackSubmit(feedback: FeedbackFormData): Promise { + if (!dialog) { + return; + } + + const result = await handleFeedbackSubmit(dialog, feedback, {referrer: options.referrer}); + + // Error submitting feedback + if (!result) { + if (options.onSubmitError) { + options.onSubmitError(); + } + + return; + } + + // Success + removeDialog(); + showSuccessMessage(); + + if (options.onSubmitSuccess) { + options.onSubmitSuccess(); + } + } + + /** + * Displays the default actor + */ + function showActor(): void { + actor && actor.show(); + } + + /** + * Hides the default actor + */ + function hideActor(): void { + actor && actor.hide(); + } + + /** + * Removes the default actor element + */ + function removeActor(): void { + actor && actor.$el.remove(); + } + + /** + * + */ + function openDialog(): void { + try { + if (dialog) { + dialog.open(); + isDialogOpen = true; + if (options.onDialogOpen) { + options.onDialogOpen(); + } + return; + } + + const userKey = !options.isAnonymous && options.useSentryUser; + const scope = getCurrentHub().getScope(); + const user = scope && scope.getUser(); + + dialog = Dialog({ + defaultName: (userKey && user && user[userKey.name]) || '', + defaultEmail: (userKey && user && user[userKey.email]) || '', + onClosed: () => { + showActor(); + isDialogOpen = false; + }, + onCancel: () => { + hideDialog(); + showActor(); + }, + onSubmit: _handleFeedbackSubmit, + options, + }); + + shadow.appendChild(dialog.$el); + + // Hides the default actor whenever dialog is opened + hideActor(); + + if (options.onDialogOpen) { + options.onDialogOpen(); + } + } catch (err) { + // TODO: Error handling? + logger.error(err); + } + } + + /** + * Hides the dialog + */ + function hideDialog(): void { + if (dialog) { + dialog.close(); + isDialogOpen = false; + + if (options.onDialogClose) { + options.onDialogClose(); + } + } + } + + /** + * Removes the dialog element from DOM + */ + function removeDialog(): void { + if (dialog) { + hideDialog(); + dialog.$el.remove(); + dialog = undefined; + } + } + + /** + * + */ + function handleActorClick(): void { + // Open dialog + if (!isDialogOpen) { + openDialog(); + } + + // Hide actor button + hideActor(); + + if (options.onActorClick) { + options.onActorClick(); + } + } + + if (!attachTo) { + actor = Actor({ options, onClick: handleActorClick }); + shadow.appendChild(actor.$el); + } else { + attachTo.addEventListener('click', handleActorClick); + } + + return { + actor, + dialog, + + showActor, + hideActor, + removeActor, + + openDialog, + hideDialog, + removeDialog, + }; +} diff --git a/packages/feedback/src/widget/util/createElement.ts b/packages/feedback/src/widget/util/createElement.ts index c9760be7e3db..bf5f81868d68 100644 --- a/packages/feedback/src/widget/util/createElement.ts +++ b/packages/feedback/src/widget/util/createElement.ts @@ -1,3 +1,5 @@ +import { WINDOW } from '@sentry/browser'; + /** * Helper function to create an element. Could be used as a JSX factory * (i.e. React-like syntax). @@ -7,7 +9,8 @@ export function createElement( attributes: { [key: string]: string | boolean | EventListenerOrEventListenerObject } | null, ...children: any ): HTMLElementTagNameMap[K] { - const element = document.createElement(tagName); + const doc = WINDOW.document; + const element = doc.createElement(tagName); if (attributes) { Object.entries(attributes).forEach(([attribute, attributeValue]) => { @@ -31,6 +34,7 @@ export function createElement( } function appendChild(parent: Node, child: any): void { + const doc = WINDOW.document; if (typeof child === 'undefined' || child === null) { return; } @@ -42,10 +46,10 @@ function appendChild(parent: Node, child: any): void { } else if (child === false) { // do nothing if child evaluated to false } else if (typeof child === 'string') { - parent.appendChild(document.createTextNode(child)); + parent.appendChild(doc.createTextNode(child)); } else if (child instanceof Node) { parent.appendChild(child); } else { - parent.appendChild(document.createTextNode(String(child))); + parent.appendChild(doc.createTextNode(String(child))); } } diff --git a/yarn.lock b/yarn.lock index 9f19615e7308..5927215390a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4958,28 +4958,6 @@ fflate "^0.4.4" mitt "^3.0.0" -"@sentry-internal/tracing@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.70.0.tgz#00fd30426a6d4737385004434a39cf60736beafc" - integrity sha512-SpbE6wZhs6QwG2ORWCt8r28o1T949qkWx/KeRTCdK4Ub95PQ3Y3DgnqD8Wz//3q50Wt6EZDEibmz4t067g6PPg== - dependencies: - "@sentry/core" "7.70.0" - "@sentry/types" "7.70.0" - "@sentry/utils" "7.70.0" - tslib "^2.4.1 || ^1.9.3" - -"@sentry/browser@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.70.0.tgz#e284999843bebc5bccd2c7b247f01aa048518f5c" - integrity sha512-PB+IP49/TLcnDHCj9eJ5tcHE0pzXg23wBakmF3KGMSd5nxEbUvmOsaFPZcgUUlL9JlU3v1Y40We7HdPStrY6oA== - dependencies: - "@sentry-internal/tracing" "7.70.0" - "@sentry/core" "7.70.0" - "@sentry/replay" "7.70.0" - "@sentry/types" "7.70.0" - "@sentry/utils" "7.70.0" - tslib "^2.4.1 || ^1.9.3" - "@sentry/bundler-plugin-core@0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-0.6.1.tgz#6c6a2ff3cdc98cd0ff1c30c59408cee9f067adf2" @@ -5054,37 +5032,6 @@ proxy-from-env "^1.1.0" which "^2.0.2" -"@sentry/core@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.70.0.tgz#c481ef27cf05293fb681ee4ff4d4b0b1e8664bb5" - integrity sha512-voUsGVM+jwRp99AQYFnRvr7sVd2tUhIMj1L6F42LtD3vp7t5ZnKp3NpXagtFW2vWzXESfyJUBhM0qI/bFvn7ZA== - dependencies: - "@sentry/types" "7.70.0" - "@sentry/utils" "7.70.0" - tslib "^2.4.1 || ^1.9.3" - -"@sentry/replay@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.70.0.tgz#fd0c75cb0d632e15c8d270af33cb157d328399e8" - integrity sha512-XjnyE6ORREz9kBWWHdXaIjS9P2Wo7uEw+y23vfLQwzV0Nx3xJ+FG4dwf8onyIoeCZDKbz7cqQIbugU1gkgUtZw== - dependencies: - "@sentry/core" "7.70.0" - "@sentry/types" "7.70.0" - "@sentry/utils" "7.70.0" - -"@sentry/types@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.70.0.tgz#c7b533bb18144e3b020550b38cf4812c32d05ffe" - integrity sha512-rY4DqpiDBtXSk4MDNBH3dwWqfPbNBI/9GA7Y5WJSIcObBtfBKp0fzYliHJZD0pgM7d4DPFrDn42K9Iiumgymkw== - -"@sentry/utils@7.70.0": - version "7.70.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.70.0.tgz#825387ceb10cbb1e145357394b697a1a6d60eb74" - integrity sha512-0cChMH0lsGp+5I3D4wOHWwjFN19HVrGUs7iWTLTO5St3EaVbdeLbI1vFXHxMxvopbwgpeZafbreHw/loIdZKpw== - dependencies: - "@sentry/types" "7.70.0" - tslib "^2.4.1 || ^1.9.3" - "@sentry/vite-plugin@^0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.6.1.tgz#31eb744e8d87b1528eed8d41433647727a62e7c0" @@ -29596,7 +29543,7 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, "tslib@^2.4.1 || ^1.9.3": +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1: version "2.5.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==