diff --git a/packages/feedback/src/integration.ts b/packages/feedback/src/integration.ts index 3918a44302a2..052d57353957 100644 --- a/packages/feedback/src/integration.ts +++ b/packages/feedback/src/integration.ts @@ -16,7 +16,7 @@ import { SUBMIT_BUTTON_LABEL, SUCCESS_MESSAGE_TEXT, } from './constants'; -import type { FeedbackInternalOptions, OptionalFeedbackConfiguration, Widget } from './types'; +import type { FeedbackInternalOptions, FeedbackWidget, OptionalFeedbackConfiguration } from './types'; import { mergeOptions } from './util/mergeOptions'; import { createActorStyles } from './widget/Actor.css'; import { createShadowHost } from './widget/createShadowHost'; @@ -48,12 +48,12 @@ export class Feedback implements Integration { /** * Reference to widget element that is created when autoInject is true */ - private _widget: Widget | null; + private _widget: FeedbackWidget | null; /** * List of all widgets that are created from the integration */ - private _widgets: Set; + private _widgets: Set; /** * Reference to the host element where widget is inserted @@ -166,15 +166,7 @@ export class Feedback implements Integration { } try { - // TODO: This is only here for hot reloading - if (this._host) { - this.remove(); - } - const existingFeedback = doc.querySelector(`#${this.options.id}`); - if (existingFeedback) { - existingFeedback.remove(); - } - // TODO: End hotloading + this._cleanupWidgetIfExists(); const { autoInject } = this.options; @@ -183,20 +175,49 @@ export class Feedback implements Integration { return; } - this._widget = this._createWidget(this.options); + this._createWidget(this.options); } catch (err) { __DEBUG_BUILD__ && logger.error(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): Widget | null { + public attachTo(el: Element | string, optionOverrides?: OptionalFeedbackConfiguration): FeedbackWidget | null { try { - const options = mergeOptions(this.options, optionOverrides); + const options = mergeOptions(this.options, optionOverrides || {}); - return this._ensureShadowHost(options, ({ shadow }) => { + return this._ensureShadowHost(options, ({ shadow }) => { const targetEl = typeof el === 'string' ? doc.querySelector(el) : typeof el.addEventListener === 'function' ? el : null; @@ -207,6 +228,11 @@ export class Feedback implements Integration { const widget = createWidget({ shadow, options, attachTo: targetEl }); this._widgets.add(widget); + + if (!this._widget) { + this._widget = widget; + } + return widget; }); } catch (err) { @@ -218,9 +244,11 @@ export class Feedback implements Integration { /** * Creates a new widget. Accepts partial options to override any options passed to constructor. */ - public createWidget(optionOverrides: OptionalFeedbackConfiguration): Widget | null { + public createWidget( + optionOverrides?: OptionalFeedbackConfiguration & { shouldCreateActor?: boolean }, + ): FeedbackWidget | null { try { - return this._createWidget(mergeOptions(this.options, optionOverrides)); + return this._createWidget(mergeOptions(this.options, optionOverrides || {})); } catch (err) { __DEBUG_BUILD__ && logger.error(err); return null; @@ -230,7 +258,7 @@ export class Feedback implements Integration { /** * Removes a single widget */ - public removeWidget(widget: Widget | null | undefined): boolean { + public removeWidget(widget: FeedbackWidget | null | undefined): boolean { if (!widget) { return false; } @@ -240,6 +268,12 @@ export class Feedback implements Integration { 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) { @@ -249,6 +283,13 @@ export class Feedback implements Integration { 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) */ @@ -270,11 +311,25 @@ export class Feedback implements Integration { 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): Widget | null { - return this._ensureShadowHost(options, ({ shadow }) => { + protected _createWidget(options: FeedbackInternalOptions & { shouldCreateActor?: boolean }): FeedbackWidget | null { + return this._ensureShadowHost(options, ({ shadow }) => { const widget = createWidget({ shadow, options }); if (!this._hasInsertedActorStyles && widget.actor) { @@ -283,6 +338,11 @@ export class Feedback implements Integration { } this._widgets.add(widget); + + if (!this._widget) { + this._widget = widget; + } + return widget; }); } diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts index a4b9569c1a74..e73868169b16 100644 --- a/packages/feedback/src/types/index.ts +++ b/packages/feedback/src/types/index.ts @@ -341,7 +341,7 @@ export interface FeedbackComponent { * - dialog + feedback form * - shadow root? */ -export interface Widget { +export interface FeedbackWidget { actor: ActorComponent | undefined; dialog: DialogComponent | undefined; @@ -350,6 +350,6 @@ export interface Widget { removeActor: () => void; openDialog: () => void; - hideDialog: () => void; + closeDialog: () => void; removeDialog: () => void; } diff --git a/packages/feedback/src/widget/createWidget.ts b/packages/feedback/src/widget/createWidget.ts index 688e82bfeae1..480e3d476219 100644 --- a/packages/feedback/src/widget/createWidget.ts +++ b/packages/feedback/src/widget/createWidget.ts @@ -1,7 +1,7 @@ import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; -import type { FeedbackFormData, FeedbackInternalOptions, Widget } from '../types'; +import type { FeedbackFormData, FeedbackInternalOptions, FeedbackWidget } from '../types'; import { handleFeedbackSubmit } from '../util/handleFeedbackSubmit'; import type { ActorComponent } from './Actor'; import { Actor } from './Actor'; @@ -10,15 +10,35 @@ import { Dialog } from './Dialog'; import { SuccessMessage } from './SuccessMessage'; interface CreateWidgetParams { + /** + * Shadow DOM to append to + */ shadow: ShadowRoot; - options: FeedbackInternalOptions; + + /** + * Feedback integration options + */ + options: FeedbackInternalOptions & { shouldCreateActor?: boolean }; + + /** + * An element to attach to, that when clicked, will open a dialog + */ attachTo?: Element; + + /** + * If false, will not create an actor + */ + shouldCreateActor?: boolean; } /** * Creates a new widget. Returns public methods that control widget behavior. */ -export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): Widget { +export function createWidget({ + shadow, + options: { shouldCreateActor = true, ...options }, + attachTo, +}: CreateWidgetParams): FeedbackWidget { let actor: ActorComponent | undefined; let dialog: DialogComponent | undefined; let isDialogOpen: boolean = false; @@ -159,7 +179,7 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): } }, onCancel: () => { - hideDialog(); + closeDialog(); showActor(); }, onSubmit: _handleFeedbackSubmit, @@ -184,9 +204,9 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): } /** - * Hides the dialog + * Closes the dialog */ - function hideDialog(): void { + function closeDialog(): void { if (dialog) { dialog.close(); isDialogOpen = false; @@ -202,7 +222,7 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): */ function removeDialog(): void { if (dialog) { - hideDialog(); + closeDialog(); const dialogEl = dialog.el; dialogEl && dialogEl.remove(); dialog = undefined; @@ -226,11 +246,11 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): } } - if (!attachTo) { + if (attachTo) { + attachTo.addEventListener('click', handleActorClick); + } else if (shouldCreateActor) { actor = Actor({ buttonLabel: options.buttonLabel, onClick: handleActorClick }); actor.el && shadow.appendChild(actor.el); - } else { - attachTo.addEventListener('click', handleActorClick); } return { @@ -246,7 +266,7 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams): removeActor, openDialog, - hideDialog, + closeDialog, removeDialog, }; } diff --git a/packages/feedback/test/integration.test.ts b/packages/feedback/test/integration.test.ts new file mode 100644 index 000000000000..6857e6ce7897 --- /dev/null +++ b/packages/feedback/test/integration.test.ts @@ -0,0 +1,128 @@ +import * as SentryUtils from '@sentry/utils'; + +import { ACTOR_LABEL } from '../src/constants'; +import { Feedback } from '../src/integration'; + +jest.spyOn(SentryUtils, 'isBrowser').mockImplementation(() => true); + +jest.mock('../src/util/sendFeedbackRequest', () => { + const original = jest.requireActual('../src/util/sendFeedbackRequest'); + return { + ...original, + sendFeedbackRequest: jest.fn(), + }; +}); + +describe('Feedback integration', () => { + let feedback: Feedback; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + if (feedback) { + feedback.remove(); + } + }); + + it('autoinjects widget with actor', () => { + feedback = new Feedback(); + feedback.setupOnce(); + const widget = feedback.getWidget(); + expect(widget?.actor?.el).toBeInstanceOf(HTMLButtonElement); + const actorEl = widget?.actor?.el as HTMLButtonElement; + expect(actorEl.textContent).toBe(ACTOR_LABEL); + // No dialog until actor is clicked + expect(widget?.dialog).toBeUndefined(); + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(actorEl)).toBe(true); + }); + + it('does not create a widget with `autoInject: false`', () => { + feedback = new Feedback({ autoInject: false }); + feedback.setupOnce(); + const widget = feedback.getWidget(); + expect(widget?.actor?.el).toBeUndefined(); + // No dialog until actor is clicked + expect(widget?.dialog).toBeUndefined(); + }); + + it('opens (and closes) dialog when calling `openDialog` without injecting an actor', () => { + feedback = new Feedback({ autoInject: false }); + feedback.setupOnce(); + + let widget = feedback.getWidget(); + expect(widget?.actor?.el).toBeUndefined(); + // No dialog until actor is clicked + expect(widget?.dialog).toBeUndefined(); + + feedback.openDialog(); + widget = feedback.getWidget(); + expect(widget?.actor?.el).toBeUndefined(); + expect(widget?.dialog).not.toBeUndefined(); + expect(widget?.dialog?.checkIsOpen()).toBe(true); + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(widget.dialog.el)).toBe(true); + + feedback.closeDialog(); + expect(widget?.dialog?.checkIsOpen()).toBe(false); + }); + + it('attaches to a custom actor element', () => { + const onDialogOpen = jest.fn(); + // This element is in the normal DOM + const myActor = document.createElement('div'); + myActor.textContent = 'my button'; + + feedback = new Feedback({ autoInject: false }); + let widget = feedback.getWidget(); + expect(widget).toBe(null); + + feedback.attachTo(myActor, { onDialogOpen }); + + myActor.dispatchEvent(new Event('click')); + + widget = feedback.getWidget(); + + expect(widget?.dialog?.el).toBeInstanceOf(HTMLDialogElement); + expect(widget?.dialog?.el?.open).toBe(true); + expect(onDialogOpen).toHaveBeenCalledTimes(1); + // This is all we do with `attachTo` (open dialog) + }); + + it('creates multiple widgets and removes them', () => { + feedback = new Feedback({ autoInject: false }); + + feedback.createWidget(); + expect(feedback.getWidget()?.actor?.el).toBeInstanceOf(HTMLButtonElement); + // @ts-expect-error _widgets is private + expect(feedback._widgets.size).toBe(1); + + feedback.createWidget(); + // @ts-expect-error _widgets is private + expect(feedback._widgets.size).toBe(2); + + // @ts-expect-error _widgets is private + const widgets = Array.from(feedback._widgets.values()); + expect(widgets[0]).not.toEqual(widgets[1]); + + // Both actors will be in the DOM + + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(widgets[0].actor.el)).toBe(true); + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(widgets[1].actor.el)).toBe(true); + + feedback.removeWidget(widgets[0]); + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(widgets[0].actor.el)).toBe(false); + + feedback.removeWidget(widgets[1]); + // @ts-expect-error _shadow is private + expect(feedback._shadow.contains(widgets[1].actor.el)).toBe(false); + + // @ts-expect-error _widgets is private + expect(feedback._widgets.size).toBe(0); + }); +}); diff --git a/packages/feedback/test/widget/createWidget.test.ts b/packages/feedback/test/widget/createWidget.test.ts index dda36095bf29..1a18b5b93605 100644 --- a/packages/feedback/test/widget/createWidget.test.ts +++ b/packages/feedback/test/widget/createWidget.test.ts @@ -66,7 +66,7 @@ jest.mock('../../src/util/sendFeedbackRequest', () => { }); function createShadowAndWidget( - feedbackOptions?: Partial, + feedbackOptions?: Partial & { shouldCreateActor?: boolean }, createWidgetOptions?: Partial[0]>, ) { const { shadow } = createShadowHost({ @@ -93,9 +93,20 @@ describe('createWidget', () => { }); it('creates widget with actor', () => { - const { widget } = createShadowAndWidget(); + const { shadow, widget } = createShadowAndWidget(); expect(widget.actor?.el).toBeInstanceOf(HTMLButtonElement); - expect(widget.actor?.el?.textContent).toBe(DEFAULT_OPTIONS.buttonLabel); + const actorEl = widget.actor?.el as HTMLButtonElement; + expect(actorEl.textContent).toBe(DEFAULT_OPTIONS.buttonLabel); + // No dialog until actor is clicked + expect(widget.dialog).toBeUndefined(); + expect(shadow.contains(actorEl)).toBe(true); + }); + + it('creates widget without actor', () => { + const { widget } = createShadowAndWidget({ + shouldCreateActor: false, + }); + expect(widget.actor?.el).toBeUndefined(); // No dialog until actor is clicked expect(widget.dialog).toBeUndefined(); });