import { Modal as BootstrapModal } from 'bootstrap'; import { globalState } from '../app/global-state'; import PowerduckState from '../app/powerduck-state'; import { AlertLayout } from '../components/alert/alert-layout'; import { ButtonLayout } from '../components/button/button-layout'; import { ModalConfig, ModalMobileMode } from '../components/modal/modal-config'; import { ModalUtils } from '../components/modal/modal-utils'; import { DialogIcons } from './enums/dialog-icons'; import { isNullOrEmpty } from './utils/is-null-or-empty'; import { PortalUtils } from './utils/utils'; import './../components/sweetalert2/css/sweetalert2.css'; export enum DialogResult { Confirm = 0, Cancel = 1, } type AppDialogLoadingTarget = 'content' | 'body'; const DIALOG_ALERT_LAYOUTS = { primary: AlertLayout.Primary, secondary: AlertLayout.Secondary, success: AlertLayout.Success, danger: AlertLayout.Danger, warning: AlertLayout.Warning, info: AlertLayout.Info, light: AlertLayout.Light, dark: AlertLayout.Dark, } as const; export type DialogAlertStyle = keyof typeof DIALOG_ALERT_LAYOUTS; export interface ShowPromptExOptions { /** * Async validation hook. Invoked on each OK press with the current input * value and the live dialog handle. While it runs, OK is disabled and a * blocker is shown (handle.showLoading()). Resolution semantics: * - resolves `true` (or `void`/`undefined`) → submit ACCEPTED: the outer * promise settles with the value and the dialog auto-hides. * - resolves `false` → submit REJECTED: the dialog stays open, input is * preserved, OK is re-enabled. (The hook is expected to have called * handle.showError(...) itself.) * - throws / rejects → same as `false`, but DialogUtils also surfaces a * generic error via handle.showError() ONLY if the hook rendered none. */ onSubmit?: ( value: string, handle: AppDialogExResult, ) => boolean | void | Promise; /** * Render `defaultValue` as a real prefilled input value (not just a * placeholder). Default false → legacy placeholder behavior. */ prefill?: boolean; /** input `type` attribute (e.g. 'password' for the checkout prompt). Default 'text'. */ inputType?: string; } export class AppDialogExResult { constructor( buttonCtx: HTMLElement | null, modalCtx: HTMLElement, data: T, ) { this.result = data; this.buttonContext = buttonCtx; this.modalContext = modalCtx; } result: T; buttonContext: HTMLElement | null; modalContext: HTMLElement; showLoading(target?: AppDialogLoadingTarget) { const slot = this.modalContext.querySelector(target == 'body' ? '.modal-body' : '.modal-content'); if (slot == null) { return; } // PowerduckState.getBlockerHtml() returns an HTML string; parse it once // and append the resulting element rather than re-evaluating innerHTML // (which would clobber existing children). const template = document.createElement('template'); template.innerHTML = PowerduckState.getBlockerHtml().trim(); template.content.childNodes.forEach(node => slot.appendChild(node)); } hideLoading() { this.modalContext.querySelectorAll(PowerduckState.getBlockerSelector()).forEach(el => el.remove()); } hideModal() { BootstrapModal.getOrCreateInstance(this.modalContext).hide(); } /** * Renders a single dismissable Bootstrap alert prepended into the dialog's * `.modal-body`. Last-write-wins: repeated calls replace the previous alert * rather than stacking. The `message` is HTML-escaped (it is expected to be * localized server text, so it must not inject markup). */ showAlert(style: DialogAlertStyle, message: string) { const slot = this._ensureAlertSlot(); if (slot == null) { return; } const layoutClass = DIALOG_ALERT_LAYOUTS[style]; const alert = document.createElement('div'); alert.className = `${layoutClass} alert-dismissible fade show`; alert.setAttribute('role', 'alert'); alert.innerHTML = `${PortalUtils.htmlEscape(message)}`; // Single active alert — clear the slot, then insert the new one. while (slot.firstChild != null) { slot.removeChild(slot.firstChild); } slot.appendChild(alert); // Dismiss via a manual click listener (does not depend on Bootstrap's // Alert JS being bundled). DialogUtils is a plain static class, so no // vue-facing-decorator `this`-rebinding gotcha applies here. alert.querySelector('.btn-close')?.addEventListener('click', () => { alert.remove(); }); } /** Sugar for showAlert('danger', message). */ showError(message: string) { this.showAlert('danger', message); } /** Removes any in-dialog alert currently rendered in the alert slot. */ clearAlerts() { const slot = this.modalContext.querySelector('.modal-body .dialog-alert-slot'); if (slot == null) { return; } while (slot.firstChild != null) { slot.removeChild(slot.firstChild); } } private _ensureAlertSlot(): HTMLElement | null { const body = this.modalContext.querySelector('.modal-body'); if (body == null) { return null; } let slot = body.querySelector('.dialog-alert-slot'); if (slot == null) { slot = document.createElement('div'); slot.className = 'dialog-alert-slot'; body.insertBefore(slot, body.firstChild); } return slot; } } export interface AppDialogButton { text: string; onClick: (buttonContext?: HTMLElement | null) => void; layout?: ButtonLayout; icon?: string; iconOnRight?: boolean; outlined?: boolean; cssClass?: string; } export interface AppDialogArgs { title: string; message: string; mobileMode?: 'default' | 'bottom-sheet' | 'fullscreen'; icon?: DialogIcons; autoHide?: boolean; boxColor?: string; fullHeight?: boolean; buttons: AppDialogButton[]; onHidden?: () => void; cssClass?: string; size?: 'modal-fw' | 'modal-sm' | 'modal-lg' | 'modal-xl'; } class DialogIconUtils { static getIcon(icon: DialogIcons, iconStub: boolean): string { if (icon == DialogIcons.Question) { return '
?
'; } else if (icon == DialogIcons.Warning) { return '
!
'; } else if (icon == DialogIcons.Info) { return '
i
'; } else if (icon == DialogIcons.Success) { if (iconStub) { return '
'; } return `
`; } else if (icon == DialogIcons.Error) { if (iconStub) { return '
'; } return `
`; } return ''; } } class DialogBuilder { args: AppDialogArgs = null; builder: string = null; hasIcon: boolean = false; id: string = null; labelId: string = null; isBottomSheet: boolean = false; build(args: AppDialogArgs): string { this.args = args; this.id = `dyn-modal-${PortalUtils.randomString(6)}`; this.labelId = `${this.id}label`; this.hasIcon = args.icon != null; this.isBottomSheet = this._treatAsBottomSheet(); this.builder = ''; this.builder += `'; return this.builder; } /** * Mirrors the `Modal` component's `treatMobileAsBottomSheet()` by delegating * to the shared `ModalConfig.useBottomSheet()` (mobile device, portrait, * viewport up to `sheetModalMaxWidth`). `mobileMode` defaults to the * bottom-sheet behavior (matching the pre-existing drag-handle binding) * regardless of `ModalConfig.defaultMobileMode`, so both the unset and * 'bottom-sheet' cases pass an explicit sheet request; 'default'/'fullscreen' * opt out. */ private _treatAsBottomSheet(): boolean { if (this.args.mobileMode != null && this.args.mobileMode != 'bottom-sheet') { return false; } return ModalConfig.useBottomSheet(ModalMobileMode.BottomSheetModal); } private _getMobileModeCss(): string { if (this.isBottomSheet) { return ' modal-bottom-sheet'; } else if (this.args.mobileMode == 'fullscreen') { return ' modal-mobile-fullscreen'; } return ''; } private _getHeader(): string { let innerBuilder = ''; innerBuilder += `'; return innerBuilder; } private _getBody(): string { return ( `` ); } private _getFooter(): string { let innerBuilder = ''; if (!isNullOrEmpty(this.args.buttons)) { innerBuilder += ''; } return innerBuilder; } private _getIcon(): string { return DialogIconUtils.getIcon(this.args.icon, true); } } export class DialogUtils { static primaryButtonLayout = ButtonLayout.Primary; static secondaryButtonLayout = ButtonLayout.Default; private static _buildAndShow(args: AppDialogArgs): HTMLElement { const builder = new DialogBuilder(); const html = builder.build(args); const callbackFired = false; const template = document.createElement('template'); template.innerHTML = html.trim(); const modalElement = template.content.firstElementChild as HTMLElement | null; if (modalElement == null) { throw new Error('DialogBuilder produced empty markup'); } document.body.appendChild(modalElement); args.buttons.forEach((btn, i) => { const btnEl = document.getElementById(`${builder.id}-button-${i}`); btnEl?.addEventListener('click', () => { btn.onClick(); }); }); const modalContext = BootstrapModal.getOrCreateInstance(`#${builder.id}`); modalElement.addEventListener('shown.bs.modal', () => { setTimeout(() => { modalElement.querySelector('input')?.focus(); }, 75); }); modalElement.addEventListener('hidden.bs.modal', () => { if (!callbackFired && args.onHidden != null) { args.onHidden(); } setTimeout(() => { modalElement.remove(); }, 50); }); if (builder.isBottomSheet) { const modal = document.getElementById(builder.id); ModalUtils.bindModalBottomSheetHandle(() => modal, () => ModalUtils.hideModal(modalContext)); } const ensureIconAnimation = function ( selector: string, icon: DialogIcons, addExtended: boolean, ) { const stub = modalElement.querySelector(selector); if (stub != null) { const headerIcon = modalElement.querySelector('.modal-header-icon'); if (headerIcon == null) { return; } if (addExtended) { headerIcon.parentElement?.classList.add('swal-extended-icon'); } setTimeout(() => { headerIcon.innerHTML = DialogIconUtils.getIcon(icon, false); }, addExtended ? 0 : 300); } }; ensureIconAnimation( '.success-icon-stub', DialogIcons.Success, true, ); ensureIconAnimation( '.error-icon-stub', DialogIcons.Error, false, ); modalContext.show(); return modalElement; } /** * Shows barebone dialog * @param args Modal display args */ static showDialogEx(args: AppDialogArgs): Promise> { return new Promise((resolve) => { const modalContext = this._buildAndShow(args); resolve(new AppDialogExResult( null, modalContext, null, )); }); } /** * Show confirm dialog * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param yesButton Caption of the CONFIRM button * @param noButton Caption of the CANCEL button * @param icon Icon of the dialog */ static showConfirmDialog( titleHtml: string, messageHtml: string, yesButton: string = PowerduckState.getResourceValue('yes'), noButton: string = PowerduckState.getResourceValue('no'), icon: DialogIcons = DialogIcons.Warning, ): Promise { return new Promise((resolve) => { this.showConfirmDialogEx( titleHtml, messageHtml, yesButton, noButton, icon, ).then((dialogHandle) => { dialogHandle.hideModal(); resolve(dialogHandle.result); }); }); } /** * Show confirm dialog with ability to delay hiding of the dialog * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param yesButton Caption of the CONFIRM button * @param noButton Caption of the CANCEL button * @param icon Icon of the dialog */ static showConfirmDialogEx( titleHtml: string, messageHtml: string, yesButton: string = PowerduckState.getResourceValue('yes'), noButton: string = PowerduckState.getResourceValue('no'), icon: DialogIcons = DialogIcons.Warning, ): Promise> { return new Promise((resolve) => { const modalContext = this._buildAndShow({ autoHide: false, boxColor: null, icon, message: messageHtml, title: titleHtml, buttons: [ { layout: DialogUtils.secondaryButtonLayout, text: noButton || PowerduckState.getResourceValue('no'), onClick: (buttonContext) => { resolve(new AppDialogExResult( buttonContext, modalContext, DialogResult.Cancel, )); }, }, { layout: DialogUtils.primaryButtonLayout, text: yesButton || PowerduckState.getResourceValue('yes'), onClick: (buttonContext) => { resolve(new AppDialogExResult( buttonContext, modalContext, DialogResult.Confirm, )); }, }, ], onHidden: () => { setTimeout(() => { resolve(new AppDialogExResult( null, modalContext, DialogResult.Cancel, )); }, 75); }, }); }); } /** * Show informative message dialog with "OK" button * * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param icon Icon of the dialog */ static showMessageDialog( titleHtml: string, messageHtml: string, icon?: DialogIcons, ): Promise { return new Promise((resolve) => { this.showMessageDialogEx( titleHtml, messageHtml, icon, ).then((dialogHandle) => { dialogHandle.hideModal(); resolve(true); }); }); } /** * Show informative message dialog with "OK" button, ability to postpone hiding and show loading indicator * * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param icon Icon of the dialog */ static showMessageDialogEx( titleHtml: string, messageHtml: string, icon?: DialogIcons, ): Promise> { return new Promise((resolve) => { let cbFired = false; const modalContext = this._buildAndShow({ autoHide: true, boxColor: null, icon, message: messageHtml, title: titleHtml, buttons: [ { layout: DialogUtils.secondaryButtonLayout, text: 'OK', onClick: (buttonContext) => { if (!cbFired) { cbFired = true; resolve(new AppDialogExResult( buttonContext, modalContext, true, )); } }, }, ], onHidden: () => { setTimeout(() => { if (!cbFired) { cbFired = true; resolve(new AppDialogExResult( null, modalContext, true, )); } else { cbFired = false; } }, 75); }, }); }); } /** * Show informative message dialog with "OK" button * * @param messageHtml HTML string containing the message to be shown */ static showErrorMessageDialog(messageHtml: string): Promise { return this.showMessageDialog( PowerduckState.getResourceValue('error'), messageHtml, DialogIcons.Error, ); } /** * Displays prompt * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param defaultValue Default value of the input * @param icon Icon to display in the dialog header (defaults to DialogIcons.Question) */ static showPrompt( titleHtml: string, messageHtml: string, defaultValue?: string, icon: DialogIcons = DialogIcons.Question, ): Promise { return new Promise((resolve) => { this.showPromptEx( titleHtml, messageHtml, defaultValue, icon, ).then((dialogHandle) => { dialogHandle.hideModal(); resolve(dialogHandle.result); }); }); } /** * Displays prompt * @param titleHtml HTML string containing the title text of the dialog * @param messageHtml HTML string containing the message to be shown * @param defaultValue Default value of the input * @param icon Icon to display in the dialog header (defaults to DialogIcons.Question) * @param options Optional, additive. When `options.onSubmit` is supplied the OK button * becomes an internal validate-and-retry loop: on rejection the dialog stays open with * the typed input preserved; on acceptance it settles and auto-hides. When `options` is * omitted the behavior is byte-for-byte identical to the legacy prompt. */ static showPromptEx( titleHtml: string, messageHtml: string, defaultValue?: string, icon: DialogIcons = DialogIcons.Question, options?: ShowPromptExOptions, ): Promise> { return new Promise((resolve) => { let cbFired = false; let submitting = false; const inputId = `dyn-input-${PortalUtils.randomString(6)}`; const inputType = options?.inputType ?? 'text'; const valueAttr = options?.prefill ? ` value="${PortalUtils.htmlEscape(defaultValue || '')}"` : ` placeholder="${(defaultValue || '').replace(/"/g, '"')}"`; const readCurrentValue = (): string => { let currentVal = (document.getElementById(inputId) as HTMLInputElement | null)?.value ?? ''; if (isNullOrEmpty(currentVal)) { currentVal = defaultValue; } return currentVal; }; const modalContext = this._buildAndShow({ autoHide: true, boxColor: null, icon, message: `${messageHtml }

`, title: titleHtml, buttons: [ { layout: DialogUtils.secondaryButtonLayout, text: PowerduckState.getResourceValue('cancel'), onClick: (buttonContext) => { if (!cbFired) { cbFired = true; // Cancel RESOLVES the prompt (with a null result) rather than // only hiding the modal. Previously this branch hid the dialog // but never settled the promise, so any `await showPrompt(...)` // hung forever on cancel/close (the wrapping `showPrompt`'s // `.then` hides the modal again, which is harmless). Mirrors the // resolve-on-cancel contract of `showConfirmDialogEx` / // `showMessageDialogEx`. resolve(new AppDialogExResult( buttonContext, modalContext, null, )); } }, }, { layout: DialogUtils.primaryButtonLayout, text: 'OK', onClick: (buttonContext) => { const onSubmit = options?.onSubmit; if (onSubmit == null) { // Legacy path — settles once and leaves the modal OPEN (the Ex contract). if (!cbFired) { cbFired = true; resolve(new AppDialogExResult( buttonContext, modalContext, readCurrentValue(), )); } return; } // onSubmit retry loop: read value → disable OK + showLoading → await hook. if (submitting || cbFired) { return; } const currentVal = readCurrentValue(); const handle = new AppDialogExResult( buttonContext, modalContext, currentVal, ); handle.clearAlerts(); const okButton = modalContext.querySelector('.btn-primary'); submitting = true; if (okButton != null) { okButton.disabled = true; } handle.showLoading(); const rearm = () => { handle.hideLoading(); submitting = false; if (okButton != null) { okButton.disabled = false; } }; Promise.resolve() .then(() => onSubmit(currentVal, handle)) .then((accepted) => { if (accepted === false) { // Rejected — keep the dialog open, input preserved, OK re-armed. rearm(); return; } // Accepted (true / void / undefined) — settle and auto-hide. if (!cbFired) { cbFired = true; resolve(handle); } handle.hideLoading(); handle.hideModal(); }) .catch(() => { rearm(); // Surface a generic error only if the hook rendered none itself. if (modalContext.querySelector('.dialog-alert-slot .alert') == null) { handle.showError(PowerduckState.getResourceValue('error')); } }); }, }, ], onHidden: () => { setTimeout(() => { if (!cbFired) { cbFired = true; // Backdrop click / ESC / programmatic close also RESOLVES (null // result) so the promise never strands when the dialog is // dismissed without pressing a button. resolve(new AppDialogExResult( null, modalContext, null, )); } }, 75); }, }); modalContext.querySelectorAll('input').forEach((input) => { input.addEventListener('keyup', (e) => { if (e.key === 'Enter') { modalContext.querySelector('.btn-primary')?.click(); } }); }); }); } } (() => { globalState.DialogUtils = DialogUtils; })();