Skip to content

Commit

Permalink
feat(feedback): Add openDialog and closeDialog onto integration i…
Browse files Browse the repository at this point in the history
…nterface (#9464)

* Adds `openDialog` and `closeDialog` public methods on the Feedback
integration class
* Rename `hideDialog` -> `closeDialog` (for Widget)
* Rename type `Widget` -> `FeedbackWidget`
* Refactor internal logic when creating widgets
  • Loading branch information
billyvg authored Nov 7, 2023
1 parent aa27ff0 commit 170dcc5
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 37 deletions.
102 changes: 81 additions & 21 deletions packages/feedback/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Widget>;
private _widgets: Set<FeedbackWidget>;

/**
* Reference to the host element where widget is inserted
Expand Down Expand Up @@ -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;

Expand All @@ -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<Widget | null>(options, ({ shadow }) => {
return this._ensureShadowHost<FeedbackWidget | null>(options, ({ shadow }) => {
const targetEl =
typeof el === 'string' ? doc.querySelector(el) : typeof el.addEventListener === 'function' ? el : null;

Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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)
*/
Expand All @@ -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<Widget>(options, ({ shadow }) => {
protected _createWidget(options: FeedbackInternalOptions & { shouldCreateActor?: boolean }): FeedbackWidget | null {
return this._ensureShadowHost<FeedbackWidget>(options, ({ shadow }) => {
const widget = createWidget({ shadow, options });

if (!this._hasInsertedActorStyles && widget.actor) {
Expand All @@ -283,6 +338,11 @@ export class Feedback implements Integration {
}

this._widgets.add(widget);

if (!this._widget) {
this._widget = widget;
}

return widget;
});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/feedback/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export interface FeedbackComponent<T extends HTMLElement> {
* - dialog + feedback form
* - shadow root?
*/
export interface Widget {
export interface FeedbackWidget {
actor: ActorComponent | undefined;
dialog: DialogComponent | undefined;

Expand All @@ -350,6 +350,6 @@ export interface Widget {
removeActor: () => void;

openDialog: () => void;
hideDialog: () => void;
closeDialog: () => void;
removeDialog: () => void;
}
42 changes: 31 additions & 11 deletions packages/feedback/src/widget/createWidget.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -159,7 +179,7 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams):
}
},
onCancel: () => {
hideDialog();
closeDialog();
showActor();
},
onSubmit: _handleFeedbackSubmit,
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -246,7 +266,7 @@ export function createWidget({ shadow, options, attachTo }: CreateWidgetParams):
removeActor,

openDialog,
hideDialog,
closeDialog,
removeDialog,
};
}
Loading

0 comments on commit 170dcc5

Please sign in to comment.