From c7e9d3c15cef7269dfa3a6c99ab94cdf251e1199 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Thu, 23 Apr 2026 17:54:04 +0530 Subject: [PATCH 1/5] Elements: Move ia-zendesk-widget component into elements repo --- demo/story-components/story-prop-settings.ts | 11 +- .../ia-zendesk-widget-story.ts | 76 ++++++ .../ia-zendesk-widget.test.ts | 214 ++++++++++++++++ .../ia-zendesk-widget/ia-zendesk-widget.ts | 235 ++++++++++++++++++ .../ia-zendesk-widget/loader-icon.svg | 26 ++ .../ia-zendesk-widget/question-icon.svg | 27 ++ src/elements/index.ts | 1 + 7 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts create mode 100644 src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts create mode 100644 src/elements/ia-zendesk-widget/ia-zendesk-widget.ts create mode 100644 src/elements/ia-zendesk-widget/loader-icon.svg create mode 100644 src/elements/ia-zendesk-widget/question-icon.svg diff --git a/demo/story-components/story-prop-settings.ts b/demo/story-components/story-prop-settings.ts index e2533df..6572908 100644 --- a/demo/story-components/story-prop-settings.ts +++ b/demo/story-components/story-prop-settings.ts @@ -20,6 +20,7 @@ export type PropInputSettings = { defaultValue?: string; inputType?: 'text' | 'radio'; radioOptions?: string[]; + radioLabels?: string[]; }; export type PropInputData = { @@ -95,16 +96,18 @@ export class StoryPropsSettings extends LitElement { ${settings.label} ${settings.radioOptions.map( - (option) => - html` { + const label = settings.radioLabels?.[i] ?? option; + return html``, + />`; + }, )} diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts new file mode 100644 index 0000000..da4d41e --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts @@ -0,0 +1,76 @@ +import { html, LitElement, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import type { IAZendeskWidget } from './ia-zendesk-widget'; + +import './ia-zendesk-widget'; +import '@demo/story-template'; + +const WIDGET_SRC_TEST = + 'https://static.zdassets.com/ekr/snippet.js?key=6fe87bd8-d4e3-4b42-8632-be6eb933d54d'; + +const WIDGET_SRC_PROD = + 'https://static.zdassets.com/ekr/snippet.js?key=685f6dc4-48c5-411f-8463-cc6dd50abe2d'; + +const SOURCES = [ + { label: 'Test', src: WIDGET_SRC_TEST }, + { label: 'Production', src: WIDGET_SRC_PROD }, +]; + +@customElement('ia-zendesk-widget-story') +export class IAZendeskWidgetStory extends LitElement { + @state() private appliedSrc = WIDGET_SRC_TEST; + + @state() private pendingSrc = WIDGET_SRC_TEST; + + @state() private widgetMounted = false; + + private get usageExample(): string { + return ``; + } + + private applySelection(): void { + this.appliedSrc = this.pendingSrc; + this.widgetMounted = true; + const demo = this.shadowRoot?.querySelector('ia-zendesk-widget'); + if (demo) demo.widgetSrc = this.appliedSrc; + } + + render() { + return html` + + ${this.widgetMounted + ? html`` + : nothing} + +
+
+ Widget Key + ${SOURCES.map( + ({ label, src }) => html` + + `, + )} +
+ +
+
+ `; + } +} diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts new file mode 100644 index 0000000..fa64d31 --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts @@ -0,0 +1,214 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { html } from 'lit'; + +import type { IAZendeskWidget } from './ia-zendesk-widget'; +import './ia-zendesk-widget'; + +describe('IAZendeskWidget', () => { + describe('rendering', () => { + test('renders the widget title', async () => { + const el = await fixture( + html``, + ); + const title = el.shadowRoot?.querySelector('.widget-title'); + expect(title?.textContent?.trim()).toBe('Get Help'); + }); + + test('renders with default title when none is provided', async () => { + const el = await fixture( + html``, + ); + const title = el.shadowRoot?.querySelector('.widget-title'); + expect(title?.textContent?.trim()).toBe('Contact Support'); + }); + + test('renders name, email, subject, and description fields', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('#name')).toBeDefined(); + expect(el.shadowRoot?.querySelector('#email')).toBeDefined(); + expect(el.shadowRoot?.querySelector('#subject')).toBeDefined(); + expect(el.shadowRoot?.querySelector('#description')).toBeDefined(); + }); + + test('renders a submit button with the default label', async () => { + const el = await fixture( + html``, + ); + const btn = el.shadowRoot?.querySelector('.submit-btn'); + expect(btn?.textContent?.trim()).toBe('Submit'); + }); + + test('respects a custom submit-label attribute', async () => { + const el = await fixture( + html``, + ); + const btn = el.shadowRoot?.querySelector('.submit-btn'); + expect(btn?.textContent?.trim()).toBe('Send Ticket'); + }); + }); + + describe('validation', () => { + test('shows required errors when form is submitted empty', async () => { + const el = await fixture( + html``, + ); + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + + const errors = el.shadowRoot?.querySelectorAll('.field-error'); + expect(errors?.length).toBeGreaterThan(0); + }); + + test('shows an email validation error for an invalid email', async () => { + const el = await fixture( + html``, + ); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const emailInput = el.shadowRoot?.querySelector('#email'); + const descInput = el.shadowRoot?.querySelector('#description'); + if (nameInput) nameInput.value = 'Jane Doe'; + if (emailInput) emailInput.value = 'not-an-email'; + if (descInput) descInput.value = 'Some issue'; + + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + + const emailError = el.shadowRoot?.querySelector('#email-error'); + expect(emailError?.textContent).toContain('valid email'); + }); + }); + + describe('submission', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('calls the Zendesk API with correct payload', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request: { id: 42 } }), + }); + + const el = await fixture( + html``, + ); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const emailInput = el.shadowRoot?.querySelector('#email'); + const descInput = el.shadowRoot?.querySelector('#description'); + if (nameInput) nameInput.value = 'Jane Doe'; + if (emailInput) emailInput.value = 'jane@example.com'; + if (descInput) descInput.value = 'My issue details'; + + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + + expect(mockFetch).toHaveBeenCalledWith( + 'https://testco.zendesk.com/api/v2/requests.json', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + test('shows success banner after successful submission', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request: { id: 99 } }), + }); + + const el = await fixture( + html``, + ); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const emailInput = el.shadowRoot?.querySelector('#email'); + const descInput = el.shadowRoot?.querySelector('#description'); + if (nameInput) nameInput.value = 'Jane Doe'; + if (emailInput) emailInput.value = 'jane@example.com'; + if (descInput) descInput.value = 'My issue details'; + + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + // allow async fetch to resolve + await new Promise(r => setTimeout(r, 0)); + await el.updateComplete; + + const banner = el.shadowRoot?.querySelector('.banner--success'); + expect(banner).toBeTruthy(); + expect(el.shadowRoot?.querySelector('.ticket-form')).toBeNull(); + }); + + test('shows error banner when API call fails', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 422 }); + + const el = await fixture( + html``, + ); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const emailInput = el.shadowRoot?.querySelector('#email'); + const descInput = el.shadowRoot?.querySelector('#description'); + if (nameInput) nameInput.value = 'Jane Doe'; + if (emailInput) emailInput.value = 'jane@example.com'; + if (descInput) descInput.value = 'My issue details'; + + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + await new Promise(r => setTimeout(r, 0)); + await el.updateComplete; + + const banner = el.shadowRoot?.querySelector('.banner--error'); + expect(banner).toBeTruthy(); + }); + + test('fires ticketSubmitted event with ticket id and form values', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ request: { id: 77 } }), + }); + + const el = await fixture( + html``, + ); + + let eventDetail: CustomEvent | undefined; + el.addEventListener('ticketSubmitted', (e) => { + eventDetail = e as CustomEvent; + }); + + const nameInput = el.shadowRoot?.querySelector('#name'); + const emailInput = el.shadowRoot?.querySelector('#email'); + const descInput = el.shadowRoot?.querySelector('#description'); + if (nameInput) nameInput.value = 'Jane Doe'; + if (emailInput) emailInput.value = 'jane@example.com'; + if (descInput) descInput.value = 'My issue details'; + + const form = el.shadowRoot?.querySelector('.ticket-form'); + form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; + await new Promise(r => setTimeout(r, 0)); + await el.updateComplete; + + expect(eventDetail).toBeDefined(); + expect(eventDetail?.detail.ticketId).toBe(77); + expect(eventDetail?.detail.formValues.name).toBe('Jane Doe'); + }); + }); +}); diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts new file mode 100644 index 0000000..798d69c --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts @@ -0,0 +1,235 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { unsafeHTML, UnsafeHTMLDirective } from 'lit/directives/unsafe-html.js'; + +import loaderIconSvg from './loader-icon.svg?raw'; +import questionIconSvg from './question-icon.svg?raw'; +import { DirectiveResult } from 'lit/directive.js'; + +declare global { + interface Window { + /** + * Zendesk Messenger API injected by the ze-snippet script. + * Overloaded to cover the two call shapes we use: + * - `messenger:on` — subscribe to open/close lifecycle events + * - `messenger` — imperatively open or close the widget panel + */ + zE?: { + ( + target: 'messenger:on', + event: 'open' | 'close', + callback: () => void, + ): void; + (target: 'messenger', action: 'open' | 'close'): void; + }; + } +} + +/** + * A lightweight launcher button that loads and opens the Zendesk Messenger + * widget on demand. + * + * The native Zendesk launcher is never shown — this button is the sole entry + * point. The Zendesk snippet script is fetched lazily on the first click so + * it does not affect page load performance. + * + * @fires zendeskHelpButtonClicked - Dispatched when the user clicks the Help + * button, before the widget is opened. + * + * @cssprop --button-blue - Button background colour (default: `#194880`). + * @cssprop --icon-fill-color - SVG icon fill colour (default: `#fff`). + * @cssprop --link-color - Button text colour (default: `#fff`). + * + * @example + * ```html + * + * ``` + */ +@customElement('ia-zendesk-widget') +export class IAZendeskWidget extends LitElement { + /** URL of the Zendesk `ze-snippet` loader script, including the `key` query param. */ + @property({ type: String, attribute: 'widget-src' }) + widgetSrc = ''; + + /** Controls Help button visibility. Hidden while the widget panel is open. */ + @state() private buttonVisible = true; + + /** True from the first click until Zendesk fires the `open` event. Shows the spinner. */ + @state() private isLoading = false; + + /** + * Set to `true` after the snippet has loaded and the `messenger:on` listeners + * have been registered. Prevents duplicate listener registration on + * subsequent clicks. + */ + private zendeskReady = false; + + /** + * Click handler for the Help button. + * + * On the first call: injects the Zendesk script, waits for `window.zE` to + * become available, registers open/close listeners, then opens the panel. + * + * On subsequent calls: the script is already present so we go straight to + * opening the panel. The spinner stays on until Zendesk fires `'open'`. + */ + private async initiateZenDesk(): Promise { + this.isLoading = true; + this.dispatchEvent(new Event('zendeskHelpButtonClicked')); + + if (!this.zendeskReady) { + await this.loadZendeskScript(); + await this.waitForZendesk(); + + // Register lifecycle listeners exactly once. + // isLoading is cleared here (not earlier) so the spinner persists until + // the widget panel is actually visible to the user. + window.zE!('messenger:on', 'open', () => { + this.buttonVisible = false; + this.isLoading = false; + }); + + // Delay matches the Zendesk close animation so the Help button does not + // reappear while the panel is still sliding out. + window.zE!('messenger:on', 'close', () => { + setTimeout(() => { + this.buttonVisible = true; + }, 500); + }); + + this.zendeskReady = true; + } + + window.zE!('messenger', 'open'); + } + + /** + * Appends the Zendesk snippet `