diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 21a371b4bea0..31efcfca2386 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1,417 +1,2 @@ -import { getCurrentHub } from '@sentry/core'; -import type { Integration } from '@sentry/types'; -import { isNodeEnv, logger } from '@sentry/utils'; - -import type { FeedbackConfigurationWithDefaults, FeedbackFormData } from './types'; -import { handleFeedbackSubmit } from './util/handleFeedbackSubmit'; -import { sendFeedbackRequest } from './util/sendFeedbackRequest'; -import { Actor } from './widget/Actor'; -import { createActorStyles } from './widget/Actor.css'; -import { Dialog } from './widget/Dialog'; -import { createDialogStyles } from './widget/Dialog.css'; -import { SuccessMessage } from './widget/SuccessMessage'; - -export { sendFeedbackRequest }; - -type ElectronProcess = { type?: string }; - -// Electron renderers with nodeIntegration enabled are detected as Node.js so we specifically test for them -function isElectronNodeRenderer(): boolean { - return typeof process !== 'undefined' && (process as ElectronProcess).type === 'renderer'; -} -/** - * Returns true if we are in the browser. - */ -function isBrowser(): boolean { - // eslint-disable-next-line no-restricted-globals - return typeof window !== 'undefined' && (!isNodeEnv() || isElectronNodeRenderer()); -} - -const THEME = { - light: { - background: '#ffffff', - foreground: '#2B2233', - success: '#268d75', - error: '#df3338', - }, - dark: { - background: '#29232f', - foreground: '#EBE6EF', - success: '#2da98c', - error: '#f55459', - }, -}; - -/** - * 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. - */ -export class Feedback implements Integration { - /** - * @inheritDoc - */ - public static id: string = 'Feedback'; - - /** - * @inheritDoc - */ - public name: string; - - /** - * Feedback configuration options - */ - public options: FeedbackConfigurationWithDefaults; - - /** - * Reference to widget actor element (button that opens dialog). - */ - private _actor: ReturnType | null; - /** - * Reference to dialog element - */ - private _dialog: ReturnType | null; - /** - * 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 - */ - private _hasDialogOpened: boolean; - - public constructor({ - attachTo = null, - autoInject = true, - showEmail = true, - showName = true, - useSentryUser = { - email: 'email', - name: 'username', - }, - isAnonymous = true, - isEmailRequired = false, - isNameRequired = false, - - buttonLabel = 'Report a Bug', - cancelButtonLabel = 'Cancel', - submitButtonLabel = 'Send Bug Report', - formTitle = 'Report a Bug', - emailPlaceholder = 'your.email@example.org', - emailLabel = 'Email', - messagePlaceholder = "What's the bug? What did you expect?", - messageLabel = 'Description', - namePlaceholder = 'Your Name', - nameLabel = 'Name', - successMessageText = 'Thank you for your report!', - - onOpenDialog, - }: Partial = {}) { - // Initializations - this.name = Feedback.id; - this._actor = null; - this._dialog = null; - this._host = null; - this._shadow = null; - this._isDialogOpen = false; - this._hasDialogOpened = false; - - this.options = { - attachTo, - autoInject, - isAnonymous, - isEmailRequired, - isNameRequired, - showEmail, - showName, - useSentryUser, - - buttonLabel, - cancelButtonLabel, - submitButtonLabel, - formTitle, - emailLabel, - emailPlaceholder, - messageLabel, - messagePlaceholder, - nameLabel, - namePlaceholder, - successMessageText, - - onOpenDialog, - }; - - // TOOD: temp for testing; - this.setupOnce(); - } - - /** If replay has already been initialized */ - /** - * Setup and initialize replay container - */ - public setupOnce(): void { - if (!isBrowser()) { - return; - } - - try { - // TODO: This is only here for hot reloading - if (this._host) { - this.remove(); - } - const existingFeedback = document.querySelector('#sentry-feedback'); - if (existingFeedback) { - existingFeedback.remove(); - } - - // TODO: End hotloading - - try { - this._shadow = this._createShadowHost(); - } catch (err) { - return; - } - - // Only create widget actor if `attachTo` was not defined - if (this.options.attachTo) { - const actorTarget = typeof this.options.attachTo === 'string' ? document.querySelector(this.options.attachTo) : typeof this.options.attachTo === 'function' ? this.options.attachTo : null; - - if (!actorTarget) { - logger.warn(`[Feedback] Unable to find element with selector ${actorTarget}`); - return; - } - - actorTarget.addEventListener('click', this._handleActorClick); - } else if (this.options.autoInject) { - // Only - this._createWidgetActor(); - } - - if (!this._host) { - return; - } - - document.body.appendChild(this._host); - } catch (err) { - // TODO: error handling - console.error(err); - } - } - - /** - * Removes the Feedback widget - */ - public remove(): void { - if (this._host) { - this._host.remove(); - } - } - - /** - * Opens the Feedback dialog form - */ - public openDialog(): void { - try { - if (this._dialog) { - this._dialog.open(); - this._isDialogOpen = true; - console.log('dialog already open') - return; - } - - try { - this._shadow = this._createShadowHost(); - } catch { - return; - } - - console.log('open dialog', this._shadow) - // Lazy-load until dialog is opened and only inject styles once - if (!this._hasDialogOpened) { - this._shadow.appendChild(createDialogStyles(document, THEME)); - } - - const userKey = this.options.useSentryUser; - const scope = getCurrentHub().getScope(); - const user = scope && scope.getUser(); - - this._dialog = Dialog({ - defaultName: (userKey && user && user[userKey.name]) || '', - defaultEmail: (userKey && user && user[userKey.email]) || '', - onClose: () => { - this.showActor(); - this._isDialogOpen = false; - }, - onCancel: () => { - this.hideDialog(); - this.showActor(); - }, - onSubmit: this._handleFeedbackSubmit, - options: this.options, - }); - this._shadow.appendChild(this._dialog.$el); - console.log(this._dialog.$el); - - // Hides the default actor whenever dialog is opened - this._actor && this._actor.hide(); - - this._hasDialogOpened = true; - } catch (err) { - // TODO: Error handling? - console.error(err); - } - } - - /** - * Hides the dialog - */ - public hideDialog = (): void => { - if (this._dialog) { - this._dialog.close(); - this._isDialogOpen = false; - } - }; - - /** - * Removes the dialog element from DOM - */ - public removeDialog = (): void => { - if (this._dialog) { - this._dialog.$el.remove(); - this._dialog = null; - } - }; - - /** - * Displays the default actor - */ - public showActor = (): void => { - // TODO: Only show default actor - if (this._actor) { - this._actor.show(); - } - }; - - /** - * Creates the host element of widget's shadow DOM. Returns null if not supported. - */ - 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; - } - - // Create the host - this._host = document.createElement('div'); - this._host.id = 'sentry-feedback'; - - // Create the shadow root - const shadow = this._host.attachShadow({ mode: 'open' }); - - return shadow; - } - - /** - * Creates the host element of our shadow DOM as well as the actor - */ - 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, THEME)); - - // Create Actor component - this._actor = Actor({ options: this.options, theme: THEME, onClick: this._handleActorClick }); - - this._shadow.appendChild(this._actor.$el); - } catch(err) { - // TODO: error handling - console.error(err); - } - } - - /** - * Show the success message for 5 seconds - */ - protected _showSuccessMessage(): void { - if (!this._shadow) { - return; - } - - try { - const success = SuccessMessage({ - message: this.options.successMessageText, - onRemove: () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - this.showActor(); - }, - theme: THEME, - }); - - this._shadow.appendChild(success.$el); - - const timeoutId = setTimeout(() => { - if (success) { - success.remove(); - } - }, 5000); - } catch(err) { - // TODO: error handling - console.error(err); - } - } - - /** - * Handles when the actor is clicked, opens the dialog modal and calls any - * callbacks. - */ - protected _handleActorClick = (): void => { - // Open dialog - if (!this._isDialogOpen) { - this.openDialog(); - } - - // Hide actor button - if (this._actor) { - this._actor.hide(); - } - - if (this.options.onOpenDialog) { - this.options.onOpenDialog(); - } - }; - - /** - * 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); - - // Success - if (result) { - this.removeDialog(); - this._showSuccessMessage(); - } - }; -} +export { sendFeedbackRequest } from './util/sendFeedbackRequest'; +export { Feedback } from './integration'; diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts new file mode 100644 index 000000000000..d03fde336b97 --- /dev/null +++ b/packages/feedback/src/integration.ts @@ -0,0 +1,422 @@ +import { getCurrentHub } from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { isNodeEnv, logger } from '@sentry/utils'; + +import type { FeedbackConfigurationWithDefaults, FeedbackFormData } from './types'; +import { handleFeedbackSubmit } from './util/handleFeedbackSubmit'; +import { Actor } from './widget/Actor'; +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'; + +type ElectronProcess = { type?: string }; + +// Electron renderers with nodeIntegration enabled are detected as Node.js so we specifically test for them +function isElectronNodeRenderer(): boolean { + return typeof process !== 'undefined' && (process as ElectronProcess).type === 'renderer'; +} +/** + * Returns true if we are in the browser. + */ +function isBrowser(): boolean { + // eslint-disable-next-line no-restricted-globals + return typeof window !== 'undefined' && (!isNodeEnv() || isElectronNodeRenderer()); +} + +const THEME = { + light: { + background: '#ffffff', + foreground: '#2B2233', + success: '#268d75', + error: '#df3338', + }, + dark: { + background: '#29232f', + foreground: '#EBE6EF', + success: '#2da98c', + error: '#f55459', + }, +}; + +/** + * 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. + */ +export class Feedback implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Feedback'; + + /** + * @inheritDoc + */ + public name: string; + + /** + * Feedback configuration options + */ + public options: FeedbackConfigurationWithDefaults; + + /** + * Reference to widget actor element (button that opens dialog). + */ + private _actor: ReturnType | null; + /** + * Reference to dialog element + */ + private _dialog: ReturnType | null; + /** + * 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 + */ + private _hasDialogOpened: boolean; + + public constructor({ + attachTo = null, + autoInject = true, + showEmail = true, + showName = true, + useSentryUser = { + email: 'email', + name: 'username', + }, + isAnonymous = false, + isEmailRequired = false, + isNameRequired = false, + + buttonLabel = 'Report a Bug', + cancelButtonLabel = 'Cancel', + submitButtonLabel = 'Send Bug Report', + formTitle = 'Report a Bug', + emailPlaceholder = 'your.email@example.org', + emailLabel = 'Email', + messagePlaceholder = "What's the bug? What did you expect?", + messageLabel = 'Description', + namePlaceholder = 'Your Name', + nameLabel = 'Name', + successMessageText = 'Thank you for your report!', + + onOpenDialog, + }: Partial = {}) { + // Initializations + this.name = Feedback.id; + this._actor = null; + this._dialog = null; + this._host = null; + this._shadow = null; + this._isDialogOpen = false; + this._hasDialogOpened = false; + + this.options = { + attachTo, + autoInject, + isAnonymous, + isEmailRequired, + isNameRequired, + showEmail, + showName, + useSentryUser, + + buttonLabel, + cancelButtonLabel, + submitButtonLabel, + formTitle, + emailLabel, + emailPlaceholder, + messageLabel, + messagePlaceholder, + nameLabel, + namePlaceholder, + successMessageText, + + onOpenDialog, + }; + + // TOOD: temp for testing; + this.setupOnce(); + } + + /** If replay has already been initialized */ + /** + * Setup and initialize replay container + */ + public setupOnce(): void { + if (!isBrowser()) { + return; + } + + try { + // TODO: This is only here for hot reloading + if (this._host) { + this.remove(); + } + const existingFeedback = document.querySelector('#sentry-feedback'); + 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(); + } + + if (!this._host) { + logger.warn('[Feedback] Unable to create host element'); + return; + } + + document.body.appendChild(this._host); + } catch (err) { + // TODO: error handling + console.error(err); + } + } + + /** + * Removes the Feedback widget + */ + public remove(): void { + if (this._host) { + this._host.remove(); + } + } + + /** + * Opens the Feedback dialog form + */ + public openDialog(): void { + try { + if (this._dialog) { + this._dialog.open(); + this._isDialogOpen = true; + console.log('dialog already open'); + return; + } + + try { + this._shadow = this._createShadowHost(); + } catch { + return; + } + + // Lazy-load until dialog is opened and only inject styles once + if (!this._hasDialogOpened) { + this._shadow.appendChild(createDialogStyles(document)); + } + + const userKey = this.options.useSentryUser; + const scope = getCurrentHub().getScope(); + const user = scope && scope.getUser(); + + this._dialog = Dialog({ + defaultName: (userKey && user && user[userKey.name]) || '', + defaultEmail: (userKey && user && user[userKey.email]) || '', + onClose: () => { + this.showActor(); + this._isDialogOpen = false; + }, + onCancel: () => { + this.hideDialog(); + this.showActor(); + }, + onSubmit: this._handleFeedbackSubmit, + options: this.options, + }); + this._shadow.appendChild(this._dialog.$el); + + // Hides the default actor whenever dialog is opened + this._actor && this._actor.hide(); + + this._hasDialogOpened = true; + } catch (err) { + // TODO: Error handling? + console.error(err); + } + } + + /** + * Hides the dialog + */ + public hideDialog = (): void => { + if (this._dialog) { + this._dialog.close(); + this._isDialogOpen = false; + } + }; + + /** + * Removes the dialog element from DOM + */ + public removeDialog = (): void => { + if (this._dialog) { + this._dialog.$el.remove(); + this._dialog = null; + } + }; + + /** + * Displays the default actor + */ + public showActor = (): void => { + // TODO: Only show default actor + if (this._actor) { + this._actor.show(); + } + }; + + /** + * Creates the host element of widget's shadow DOM. Returns null if not supported. + */ + 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; + } + + // Create the host + this._host = document.createElement('div'); + this._host.id = 'sentry-feedback'; + + // Create the shadow root + const shadow = this._host.attachShadow({ mode: 'open' }); + + shadow.appendChild(createMainStyles(document, THEME)); + + return shadow; + } + + /** + * Creates the host element of our shadow DOM as well as the actor + */ + 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, theme: THEME, onClick: this._handleActorClick }); + + this._shadow.appendChild(this._actor.$el); + } catch (err) { + // TODO: error handling + console.error(err); + } + } + + /** + * Show the success message for 5 seconds + */ + protected _showSuccessMessage(): void { + if (!this._shadow) { + return; + } + + try { + const success = SuccessMessage({ + message: this.options.successMessageText, + onRemove: () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + this.showActor(); + }, + theme: THEME, + }); + + this._shadow.appendChild(success.$el); + + const timeoutId = setTimeout(() => { + if (success) { + success.remove(); + } + }, 5000); + } catch (err) { + // TODO: error handling + console.error(err); + } + } + + /** + * Handles when the actor is clicked, opens the dialog modal and calls any + * callbacks. + */ + protected _handleActorClick = (): void => { + // Open dialog + if (!this._isDialogOpen) { + this.openDialog(); + } + + // Hide actor button + if (this._actor) { + this._actor.hide(); + } + + if (this.options.onOpenDialog) { + this.options.onOpenDialog(); + } + }; + + /** + * 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); + + // Success + if (result) { + this.removeDialog(); + this._showSuccessMessage(); + } + }; +} diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index 7632e460c889..ee278df9d971 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -52,21 +52,22 @@ export interface FeedbackConfigurationWithDefaults { isAnonymous: boolean; /** - * Should the email field be required + * Should the email field be required? */ isEmailRequired: boolean; /** - * Should the name field be required + * Should the name field be required? */ isNameRequired: boolean; /** - * Should the email input field be visible? + * 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? + * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()` */ showName: boolean; diff --git a/packages/feedback/src/util/handleFeedbackSubmit.ts b/packages/feedback/src/util/handleFeedbackSubmit.ts index e4c67ec30248..c098eee690dc 100644 --- a/packages/feedback/src/util/handleFeedbackSubmit.ts +++ b/packages/feedback/src/util/handleFeedbackSubmit.ts @@ -2,37 +2,40 @@ import type { FeedbackFormData } from '../types'; import { DialogComponent } from '../widget/Dialog'; import { sendFeedback } from '../sendFeedback'; -export async function handleFeedbackSubmit(dialog: DialogComponent|null, feedback: FeedbackFormData): Promise { +export async function handleFeedbackSubmit( + dialog: DialogComponent | null, + feedback: FeedbackFormData, +): Promise { + if (!dialog) { + // Not sure when this would happen + return false; + } + + const showFetchError = () => { if (!dialog) { - // Not sure when this would happen - return false; + return; } + dialog.setSubmitEnabled(); + dialog.showError('There was a problem submitting feedback, please wait and try again.'); + }; - const showFetchError = () => { - if (!dialog) { - return; - } - dialog.setSubmitEnabled(); - dialog.showError('There was a problem submitting feedback, please wait and try again.'); - }; - - try { - dialog.hideError(); - dialog.setSubmitDisabled(); - const resp = await sendFeedback(feedback); - console.log({ resp }); + try { + dialog.hideError(); + dialog.setSubmitDisabled(); + const resp = await sendFeedback(feedback); + console.log({ resp }); - if (!resp) { - // Errored... re-enable submit button - showFetchError(); - return false; - } - - // Success! - return resp; - } catch { + if (!resp) { // Errored... re-enable submit button showFetchError(); return false; } + + // Success! + return resp; + } catch { + // Errored... re-enable submit button + showFetchError(); + return false; + } } diff --git a/packages/feedback/src/widget/Actor.css.ts b/packages/feedback/src/widget/Actor.css.ts index e4414383af71..3d9dbe1cf1b8 100644 --- a/packages/feedback/src/widget/Actor.css.ts +++ b/packages/feedback/src/widget/Actor.css.ts @@ -1,36 +1,9 @@ -import type { FeedbackTheme } from '../types'; - /** * Creates