diff --git a/changelog.md b/changelog.md index dc07d61..6cdbdd4 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-16 - 3.80.0 - feat(stepper,updater) +add progress-aware stepper flows and updater countdown states + +- extend dees-stepper with embedded progressbar rendering, progress state helpers, and automatic progression for async validation steps +- rework dees-updater to run as a non-cancelable two-step flow with live progress updates, optional external links, and configurable close or reload completion actions +- refresh stepper and updater demos plus documentation to showcase auto-advancing progress steps and ready-state countdown behavior + ## 2026-04-16 - 3.79.0 - feat(dees-progressbar) add status panels, terminal output, and legacy progress input support diff --git a/readme.md b/readme.md index a948bad..4ccedc1 100644 --- a/readme.md +++ b/readme.md @@ -1508,18 +1508,33 @@ const layer = await DeesWindowLayer.createAndShow({ ### Navigation Components #### `DeesStepper` -Multi-step navigation component for guided user flows. +Multi-step navigation component for guided user flows, including optional auto-advancing progress steps that can render `dees-progressbar` status output between form steps. ```typescript Form 1` }, - { key: 'address', label: 'Address', content: html`
Form 2
` }, - { key: 'confirm', label: 'Confirmation', content: html`
Review
` } + { + title: 'Account Setup', + content: html`...`, + menuOptions: [{ name: 'Continue', action: async (stepper) => stepper?.goNext() }] + }, + { + title: 'Provision Workspace', + content: html`

Preparing your environment...

`, + progressStep: { + label: 'Workspace setup', + indeterminate: true, + statusRows: 4, + terminalLines: ['Allocating workspace'] + }, + validationFunc: async (stepper, _element, signal) => { + stepper.updateProgressStep({ percentage: 35, statusText: 'Installing dependencies...' }); + stepper.appendProgressStepLine('Installing dependencies'); + if (signal?.aborted) return; + stepper.updateProgressStep({ percentage: 100, indeterminate: false, statusText: 'Workspace ready.' }); + } + } ]} - currentStep="personal" - @step-change=${handleStepChange} - @complete=${handleComplete} >
``` @@ -1580,6 +1595,33 @@ Theme provider component that wraps children and provides CSS custom properties - Works with dark/light mode - Overrides cascade to all child components +#### `DeesUpdater` +Updater controller that opens a non-cancelable `dees-stepper` flow with a progress step and a ready step. + +```typescript +const updater = await DeesUpdater.createAndShow({ + currentVersion: '3.79.0', + updatedVersion: '3.80.0', + moreInfoUrl: 'https://code.foss.global/design.estate/dees-catalog', + changelogUrl: 'https://code.foss.global/design.estate/dees-catalog/-/blob/main/changelog.md', + successAction: 'reload', + successDelayMs: 10000, +}); + +updater.updateProgress({ + percentage: 35, + statusText: 'Downloading signed bundle...', + terminalLines: ['Checking release manifest', 'Downloading signed bundle'] +}); + +updater.appendProgressLine('Verifying checksum'); +updater.updateProgress({ percentage: 72, statusText: 'Verifying checksum...' }); + +await updater.markUpdateReady(); +``` + +After `markUpdateReady()`, the updater switches to a second countdown step with a determinate progress bar and runs the configured success action when the timer reaches zero. + --- ### Workspace / IDE Components 💻 diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index b0cac0d..8bcd4dd 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.79.0', + version: '3.80.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts index 472dd3f..95c2233 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts @@ -1,7 +1,45 @@ import { html } from '@design.estate/dees-element'; import { DeesStepper, type IStep } from './dees-stepper.js'; -const demoSteps: IStep[] = [ +const waitForProgressTick = async (timeoutArg: number, signal?: AbortSignal): Promise => { + return new Promise((resolve) => { + let completed = false; + + const finish = (result: boolean) => { + if (completed) { + return; + } + + completed = true; + if (signal) { + signal.removeEventListener('abort', handleAbort); + } + resolve(result); + }; + + const handleAbort = () => { + window.clearTimeout(timeoutId); + finish(false); + }; + + const timeoutId = window.setTimeout(() => { + finish(true); + }, timeoutArg); + + if (signal) { + signal.addEventListener('abort', handleAbort, { once: true }); + } + }); +}; + +const createContinueMenuOptions = (labelArg = 'Continue') => [ + { + name: labelArg, + action: async (stepper?: DeesStepper) => stepper?.goNext(), + }, +]; + +const createDemoSteps = (): IStep[] => [ { title: 'Account Setup', content: html` @@ -10,9 +48,7 @@ const demoSteps: IStep[] = [ `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], + menuOptions: createContinueMenuOptions(), }, { title: 'Profile Details', @@ -22,9 +58,7 @@ const demoSteps: IStep[] = [ `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], + menuOptions: createContinueMenuOptions(), }, { title: 'Contact Information', @@ -34,9 +68,74 @@ const demoSteps: IStep[] = [ `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], + menuOptions: createContinueMenuOptions(), + }, + { + title: 'Provision Workspace', + content: html` + +

+ We are creating your starter workspace, applying your onboarding choices, + and preparing a live preview. This step moves forward automatically when + the environment is ready. +

+
+ `, + progressStep: { + label: 'Workspace setup', + percentage: 8, + indeterminate: true, + statusRows: 4, + statusText: 'Allocating a clean workspace...', + terminalLines: ['Allocating a clean workspace'], + }, + validationFunc: async (stepper, _htmlElement, signal) => { + const progressFrames = [ + { line: 'Allocating a clean workspace', percentage: 8, delay: 500 }, + { line: 'Syncing account preferences', percentage: 24, delay: 650 }, + { line: 'Installing selected integrations', percentage: 47, delay: 700 }, + { line: 'Generating starter project files', percentage: 71, delay: 650 }, + { line: 'Booting the live preview environment', percentage: 92, delay: 700 }, + ]; + + stepper.resetProgressStep(); + + for (const [index, progressFrame] of progressFrames.entries()) { + if (signal?.aborted) { + return; + } + + if (index === 0) { + stepper.updateProgressStep({ + percentage: progressFrame.percentage, + indeterminate: true, + statusText: `${progressFrame.line}...`, + terminalLines: [progressFrame.line], + }); + } else { + stepper.appendProgressStepLine(progressFrame.line); + stepper.updateProgressStep({ + percentage: progressFrame.percentage, + indeterminate: true, + statusText: `${progressFrame.line}...`, + }); + } + + const completed = await waitForProgressTick(progressFrame.delay, signal); + if (!completed) { + return; + } + } + + stepper.appendProgressStepLine('Workspace ready'); + stepper.updateProgressStep({ + percentage: 100, + indeterminate: false, + statusText: 'Workspace ready.', + }); + + await waitForProgressTick(350, signal); + }, }, { title: 'Team Size', @@ -55,9 +154,7 @@ const demoSteps: IStep[] = [ > `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], + menuOptions: createContinueMenuOptions(), }, { title: 'Goals', @@ -75,52 +172,31 @@ const demoSteps: IStep[] = [ > `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], - }, - { - title: 'Brand Preferences', - content: html` - - - - - `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], - }, - { - title: 'Integrations', - content: html` - - - - `, - menuOptions: [ - { name: 'Continue', action: async (stepper) => stepper!.goNext() }, - ], + menuOptions: createContinueMenuOptions(), }, { title: 'Review & Launch', content: html` -

Almost there! Review your selections and launch whenever you're ready.

+

+ Your workspace is ready. Review the collected details and launch when + you are ready to start. +

`, menuOptions: [ - { name: 'Launch', action: async (stepper) => stepper!.goNext() }, + { + name: 'Launch', + action: async (stepper?: DeesStepper) => { + if (stepper?.overlay) { + await stepper.destroy(); + } + }, + }, ], }, ]; -const cloneSteps = (): IStep[] => demoSteps.map((step) => ({ ...step })); - export const stepperDemo = () => html`
html` > { - await DeesStepper.createAndShow({ steps: cloneSteps() }); + await DeesStepper.createAndShow({ steps: createDemoSteps() }); }} >Open stepper as overlay
- +
`; diff --git a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts index 8696719..5a38d30 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts @@ -5,8 +5,6 @@ import { customElement, html, css, - unsafeCSS, - type CSSResult, cssManager, property, type TemplateResult, @@ -20,16 +18,33 @@ import { zIndexRegistry } from '../../00zindex.js'; import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js'; import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js'; import type { DeesForm } from '../../00group-form/dees-form/dees-form.js'; +import '../../00group-feedback/dees-progressbar/dees-progressbar.js'; import '../dees-tile/dees-tile.js'; +export interface IStepProgressState { + label?: string; + percentage?: number; + indeterminate?: boolean; + showPercentage?: boolean; + statusText?: string; + terminalLines?: string[]; + statusRows?: number; +} + +export interface IStepProgress extends IStepProgressState { + autoAdvance?: boolean; +} + export interface IStep { title: string; content: TemplateResult; + progressStep?: IStepProgress; menuOptions?: plugins.tsclass.website.IMenuItem[]; validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise; onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise; validationFuncCalled?: boolean; abortController?: AbortController; + progressStepState?: IStepProgressState; } declare global { @@ -276,6 +291,14 @@ export class DeesStepper extends DeesElement { padding: 32px; } + .step-body .content.withProgressStep { + padding-top: 20px; + } + + .progressStep { + padding: 0 24px; + } + /* --- Footer: modal-style bottom buttons --- */ .bottomButtons { display: flex; @@ -375,6 +398,7 @@ export class DeesStepper extends DeesElement { const isHidden = this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep); const isFirst = stepIndex === 0; + const progressStepState = stepArg.progressStep ? this.getProgressStepState(stepArg) : null; return html` @@ -390,7 +414,20 @@ export class DeesStepper extends DeesElement {
${stepArg.title}
-
${stepArg.content}
+ ${stepArg.progressStep && progressStepState ? html` +
+ +
+ ` : ''} +
${stepArg.content}
${isSelected && this.activeForm !== null && !this.activeFormValid @@ -426,22 +463,30 @@ export class DeesStepper extends DeesElement { public async firstUpdated() { await this.domtoolsPromise; await this.domtools.convenience.smartdelay.delayFor(0); + if (!this.steps.length) { + return; + } + this.prepareStepForActivation(this.steps[0]); this.selectedStep = this.steps[0]; - this.setScrollStatus(); + await this.updateComplete; + await this.setScrollStatus(); // Remove entrance class after initial animation completes await this.domtools.convenience.smartdelay.delayFor(350); this.shadowRoot!.querySelector('.step.entrance')?.classList.remove('entrance'); } - public async updated() { - this.setScrollStatus(); + public async updated(changedProperties: Map) { + if (!changedProperties.has('selectedStep') && !changedProperties.has('steps')) { + return; + } + + await this.setScrollStatus(); } public scroller!: typeof domtools.plugins.SweetScroll.prototype; public async setScrollStatus() { const stepperContainer = this.shadowRoot!.querySelector('.stepperContainer') as HTMLElement; - const firstStepElement = this.shadowRoot!.querySelector('.step') as HTMLElement; const selectedStepElement = this.shadowRoot!.querySelector('.selected') as HTMLElement; if (!selectedStepElement) { return; @@ -452,14 +497,11 @@ export class DeesStepper extends DeesElement { stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2 }px`; } - console.log('Setting scroll status'); - console.log(selectedStepElement); const scrollPosition = selectedStepElement.offsetTop - stepperContainer.offsetHeight / 2 + selectedStepElement.offsetHeight / 2; - console.log(scrollPosition); - const domtoolsInstance = await domtools.DomTools.setupDomTools(); + await domtools.DomTools.setupDomTools(); if (!this.scroller) { this.scroller = new domtools.plugins.SweetScroll( { @@ -474,7 +516,11 @@ export class DeesStepper extends DeesElement { if (!this.selectedStep.validationFuncCalled && this.selectedStep.validationFunc) { this.selectedStep.abortController = new AbortController(); this.selectedStep.validationFuncCalled = true; - await this.selectedStep.validationFunc(this, selectedStepElement, this.selectedStep.abortController.signal); + void this.runStepValidation( + this.selectedStep, + selectedStepElement, + this.selectedStep.abortController.signal, + ); } this.scroller.to(scrollPosition); } @@ -492,6 +538,7 @@ export class DeesStepper extends DeesElement { currentStep.validationFuncCalled = false; const previousStep = this.steps[currentIndex - 1]; previousStep.validationFuncCalled = false; + this.prepareStepForActivation(previousStep); this.selectedStep = previousStep; await this.domtoolsPromise; await this.domtools.convenience.smartdelay.delayFor(100); @@ -511,9 +558,52 @@ export class DeesStepper extends DeesElement { currentStep.validationFuncCalled = false; const nextStep = this.steps[currentIndex + 1]; nextStep.validationFuncCalled = false; + this.prepareStepForActivation(nextStep); this.selectedStep = nextStep; } + public resetProgressStep(stepArg: IStep = this.selectedStep) { + if (!stepArg?.progressStep) { + return; + } + + stepArg.progressStepState = this.createInitialProgressStepState(stepArg); + this.requestUpdate(); + } + + public updateProgressStep( + progressStateArg: Partial, + stepArg: IStep = this.selectedStep, + ) { + if (!stepArg?.progressStep) { + return; + } + + const currentProgressState = this.getProgressStepState(stepArg); + stepArg.progressStepState = { + ...currentProgressState, + ...progressStateArg, + terminalLines: progressStateArg.terminalLines + ? [...progressStateArg.terminalLines] + : [...(currentProgressState.terminalLines ?? [])], + }; + this.requestUpdate(); + } + + public appendProgressStepLine(lineArg: string, stepArg: IStep = this.selectedStep) { + if (!stepArg?.progressStep) { + return; + } + + const currentProgressState = this.getProgressStepState(stepArg); + this.updateProgressStep( + { + terminalLines: [...(currentProgressState.terminalLines ?? []), lineArg], + }, + stepArg, + ); + } + /** * Scans the currently selected step for a in its content. When * found, subscribes to the form's RxJS changeSubject so the primary @@ -582,6 +672,74 @@ export class DeesStepper extends DeesElement { await optionArg.action(this); } + private getProgressStepState(stepArg: IStep): IStepProgressState { + if (!stepArg.progressStep) { + return {}; + } + + if (!stepArg.progressStepState) { + stepArg.progressStepState = this.createInitialProgressStepState(stepArg); + } + + return stepArg.progressStepState; + } + + private createInitialProgressStepState(stepArg: IStep): IStepProgressState { + return { + label: stepArg.progressStep?.label ?? stepArg.title, + percentage: stepArg.progressStep?.percentage ?? 0, + indeterminate: stepArg.progressStep?.indeterminate ?? false, + showPercentage: stepArg.progressStep?.showPercentage ?? true, + statusText: stepArg.progressStep?.statusText ?? '', + terminalLines: [...(stepArg.progressStep?.terminalLines ?? [])], + statusRows: stepArg.progressStep?.statusRows ?? 3, + }; + } + + private prepareStepForActivation(stepArg?: IStep) { + if (!stepArg?.progressStep) { + return; + } + + stepArg.progressStepState = this.createInitialProgressStepState(stepArg); + } + + private async runStepValidation( + stepArg: IStep, + selectedStepElement: HTMLElement, + signal: AbortSignal, + ): Promise { + try { + await stepArg.validationFunc?.(this, selectedStepElement, signal); + + if (signal.aborted) { + return; + } + + if (stepArg.progressStep && stepArg.progressStep.autoAdvance !== false && this.selectedStep === stepArg) { + this.goNext(); + } + } catch (error) { + if (signal.aborted) { + return; + } + + if (stepArg.progressStep) { + const errorText = error instanceof Error ? error.message : 'Unexpected error'; + this.appendProgressStepLine(`Error: ${errorText}`, stepArg); + this.updateProgressStep( + { + indeterminate: false, + statusText: errorText, + }, + stepArg, + ); + } + + console.error(error); + } + } + /** * Currently-open confirmation modal (if any). Prevents double-stacking when * the user clicks the backdrop or the Cancel button while a confirm modal @@ -657,6 +815,9 @@ export class DeesStepper extends DeesElement { public async destroy() { const domtools = await this.domtoolsPromise; const container = this.shadowRoot!.querySelector('.stepperContainer'); + if (this.selectedStep?.abortController) { + this.selectedStep.abortController.abort(); + } container?.classList.add('predestroy'); await domtools.convenience.smartdelay.delayFor(250); if (this.parentElement) { diff --git a/ts_web/elements/00group-utility/dees-updater/dees-updater.demo.ts b/ts_web/elements/00group-utility/dees-updater/dees-updater.demo.ts index 7c6ca71..8ca514c 100644 --- a/ts_web/elements/00group-utility/dees-updater/dees-updater.demo.ts +++ b/ts_web/elements/00group-utility/dees-updater/dees-updater.demo.ts @@ -2,9 +2,73 @@ import { html } from '@design.estate/dees-element'; import { DeesUpdater } from '../dees-updater/dees-updater.js'; -export const demoFunc = async () => { - const updater = await DeesUpdater.createAndShow(); - setTimeout(async () => { - await updater.destroy(); - }, 10000); -} \ No newline at end of file +const waitForDemoStep = async (timeoutArg: number): Promise => { + await new Promise((resolve) => { + window.setTimeout(() => resolve(), timeoutArg); + }); +}; + +export const demoFunc = () => { + let updaterRunning = false; + + return html` +
+

+ Launches the updater as a stepper flow. The first step streams terminal-style + progress updates and then moves automatically to the ready step. +

+ { + if (updaterRunning) { + return; + } + + updaterRunning = true; + + try { + const updater = await DeesUpdater.createAndShow({ + currentVersion: '3.79.0', + updatedVersion: '3.80.0', + moreInfoUrl: 'https://code.foss.global/design.estate/dees-catalog', + changelogUrl: 'https://code.foss.global/design.estate/dees-catalog/-/blob/main/changelog.md', + successAction: 'close', + successDelayMs: 10000, + }); + + const progressFrames = [ + { line: 'Checking release manifest', percentage: 12, delay: 550 }, + { line: 'Downloading signed bundle', percentage: 33, delay: 700 }, + { line: 'Verifying checksum', percentage: 51, delay: 650 }, + { line: 'Applying update files', percentage: 74, delay: 800 }, + { line: 'Cleaning up previous release', percentage: 91, delay: 600 }, + ]; + + updater.updateProgress({ + statusText: 'Checking release manifest...', + terminalLines: ['Checking release manifest'], + percentage: 12, + indeterminate: true, + }); + + for (const [index, progressFrame] of progressFrames.entries()) { + if (index > 0) { + updater.appendProgressLine(progressFrame.line); + updater.updateProgress({ + percentage: progressFrame.percentage, + statusText: `${progressFrame.line}...`, + }); + } + + await waitForDemoStep(progressFrame.delay); + } + + await updater.markUpdateReady(); + await waitForDemoStep(10500); + } finally { + updaterRunning = false; + } + }} + >Show updater flow +
+ `; +}; diff --git a/ts_web/elements/00group-utility/dees-updater/dees-updater.ts b/ts_web/elements/00group-utility/dees-updater/dees-updater.ts index b73b806..83caf9a 100644 --- a/ts_web/elements/00group-utility/dees-updater/dees-updater.ts +++ b/ts_web/elements/00group-utility/dees-updater/dees-updater.ts @@ -4,14 +4,27 @@ import { type TemplateResult, html, property, - type CSSResult, - domtools, + css, } from '@design.estate/dees-element'; import { demoFunc } from './dees-updater.demo.js'; +import { + DeesStepper, + type IStep, + type IStepProgressState, +} from '../../00group-layout/dees-stepper/dees-stepper.js'; -import '../../00group-overlay/dees-windowlayer/dees-windowlayer.js'; -import { css, cssManager } from '@design.estate/dees-element'; -import { themeDefaultStyles } from '../../00theme.js'; +export type TDeesUpdaterSuccessAction = 'close' | 'reload'; + +export interface IDeesUpdaterOptions { + currentVersion?: string; + updatedVersion?: string; + moreInfoUrl?: string; + changelogUrl?: string; + successAction?: TDeesUpdaterSuccessAction; + successDelayMs?: number; + successActionLabel?: string; + onSuccessAction?: () => Promise | void; +} declare global { interface HTMLElementTagNameMap { @@ -24,91 +37,393 @@ export class DeesUpdater extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Utility']; - public static async createAndShow() { + public static async createAndShow(optionsArg: IDeesUpdaterOptions = {}) { const updater = new DeesUpdater(); + updater.currentVersion = optionsArg.currentVersion ?? ''; + updater.updatedVersion = optionsArg.updatedVersion ?? ''; + updater.moreInfoUrl = optionsArg.moreInfoUrl ?? ''; + updater.changelogUrl = optionsArg.changelogUrl ?? ''; + updater.successAction = optionsArg.successAction ?? 'close'; + updater.successDelayMs = optionsArg.successDelayMs ?? 10000; + updater.successActionLabel = optionsArg.successActionLabel ?? ''; + updater.onSuccessAction = optionsArg.onSuccessAction ?? null; document.body.appendChild(updater); + await updater.show(); return updater; } @property({ type: String, }) - accessor currentVersion!: string; + accessor currentVersion = ''; @property({ type: String, }) - accessor updatedVersion!: string; + accessor updatedVersion = ''; - constructor() { - super(); - domtools.elementBasic.setup(); - } + @property({ + type: String, + }) + accessor moreInfoUrl = ''; + + @property({ + type: String, + }) + accessor changelogUrl = ''; + + @property({ + type: String, + }) + accessor successAction: TDeesUpdaterSuccessAction = 'close'; + + @property({ + type: Number, + }) + accessor successDelayMs = 10000; + + @property({ + type: String, + }) + accessor successActionLabel = ''; + + private stepper: DeesStepper | null = null; + private progressStep: IStep | null = null; + private showPromise: Promise | null = null; + private onSuccessAction: (() => Promise | void) | null = null; public static styles = [ - themeDefaultStyles, - cssManager.defaultStyles, css` - /* TODO: Migrate hardcoded values to --dees-* CSS variables */ - .modalContainer { - will-change: transform; - position: relative; - background: ${cssManager.bdTheme('#eeeeeb', '#222')}; - max-width: 800px; - border-radius: 8px; - border-top: 1px solid ${cssManager.bdTheme('#eeeeeb', '#333')}; - } - - .headingContainer { - display: flex; - justify-content: center; - align-items: center; - padding: 40px 40px; - } - - h1 { - margin: none; - font-size: 20px; - color: ${cssManager.bdTheme('#333', '#fff')}; - margin-left: 20px; - font-weight: normal; - } - - .buttonContainer { - display: grid; - grid-template-columns: 50% 50%; + :host { + display: none; } `, ]; public render(): TemplateResult { - return html` - -
-
- -

Updating the application...

-
-
- -
-
- More info - Changelog -
-
> - `; + return html``; + } + + public async connectedCallback(): Promise { + await super.connectedCallback(); + void this.show(); + } + + public async show(): Promise { + if (this.stepper?.isConnected) { + return; + } + + if (this.showPromise) { + return this.showPromise; + } + + this.showPromise = this.openStepperFlow(); + + try { + await this.showPromise; + } finally { + this.showPromise = null; + } + } + + public updateProgress(progressStateArg: Partial) { + if (!this.stepper || !this.progressStep) { + return; + } + + this.stepper.updateProgressStep(progressStateArg, this.progressStep); + } + + public appendProgressLine(lineArg: string) { + if (!this.stepper || !this.progressStep) { + return; + } + + this.stepper.appendProgressStepLine(lineArg, this.progressStep); + } + + public markUpdateError(messageArg: string) { + this.appendProgressLine(`Error: ${messageArg}`); + this.updateProgress({ + indeterminate: false, + statusText: messageArg, + }); + } + + public async markUpdateReady() { + if (!this.stepper || !this.progressStep) { + return; + } + + this.stepper.updateProgressStep( + { + percentage: 100, + indeterminate: false, + statusText: 'Update ready.', + }, + this.progressStep, + ); + this.stepper.appendProgressStepLine('Update ready', this.progressStep); + + if (this.stepper.selectedStep === this.progressStep) { + this.stepper.goNext(); + } } public async destroy() { - this.parentElement!.removeChild(this); + const stepper = this.stepper; + this.stepper = null; + this.progressStep = null; + + if (stepper?.isConnected) { + await stepper.destroy(); + } + + if (this.parentElement) { + this.parentElement.removeChild(this); + } } - private windowLayerClicked() {} + private async openStepperFlow() { + const { steps, progressStep } = this.createUpdaterSteps(); + this.progressStep = progressStep; + this.stepper = await DeesStepper.createAndShow({ + steps, + cancelable: false, + }); + } + + private createUpdaterSteps(): { steps: IStep[]; progressStep: IStep } { + const infoMenuOptions = this.getLinkMenuOptions(); + const progressStep: IStep = { + title: 'Updating the application', + content: this.renderProgressContent(), + progressStep: { + label: this.getProgressLabel(), + percentage: 5, + indeterminate: true, + statusRows: 4, + statusText: 'Preparing update...', + terminalLines: ['Preparing update'], + }, + menuOptions: infoMenuOptions.length > 0 ? infoMenuOptions : undefined, + }; + + const readyStep: IStep = { + title: this.updatedVersion ? `Version ${this.updatedVersion} ready` : 'Update ready', + content: this.renderReadyContent(), + progressStep: { + label: this.getSuccessCountdownLabel(this.getSuccessDelaySeconds()), + percentage: 0, + indeterminate: false, + showPercentage: false, + statusRows: 2, + statusText: this.getSuccessCountdownStatus(this.getSuccessDelaySeconds()), + }, + validationFunc: async (stepper, _htmlElement, signal) => { + await this.runSuccessCountdown(stepper, readyStep, signal); + }, + }; + + return { + steps: [progressStep, readyStep], + progressStep, + }; + } + + private getProgressLabel(): string { + if (this.currentVersion && this.updatedVersion) { + return `${this.currentVersion} -> ${this.updatedVersion}`; + } + + if (this.updatedVersion) { + return `Preparing ${this.updatedVersion}`; + } + + return 'Application update'; + } + + private getSuccessDelaySeconds(): number { + return Math.max(1, Math.ceil(this.successDelayMs / 1000)); + } + + private getSuccessActionDisplayLabel(): string { + if (this.successActionLabel) { + return this.successActionLabel; + } + + if (this.onSuccessAction) { + return 'Continuing automatically'; + } + + if (this.successAction === 'reload') { + return 'Reloading application'; + } + + return 'Closing updater'; + } + + private getSuccessCountdownLabel(secondsArg: number): string { + return `${this.getSuccessActionDisplayLabel()} in ${secondsArg}s`; + } + + private getSuccessCountdownStatus(secondsArg: number): string { + const secondLabel = secondsArg === 1 ? 'second' : 'seconds'; + return `${this.getSuccessActionDisplayLabel()} in ${secondsArg} ${secondLabel}.`; + } + + private getSuccessActionNowLabel(): string { + return `${this.getSuccessActionDisplayLabel()} now...`; + } + + private getLinkMenuOptions() { + const menuOptions: Array<{ name: string; action: () => Promise }> = []; + + if (this.moreInfoUrl) { + menuOptions.push({ + name: 'More info', + action: async () => { + this.openExternalUrl(this.moreInfoUrl); + }, + }); + } + + if (this.changelogUrl) { + menuOptions.push({ + name: 'Changelog', + action: async () => { + this.openExternalUrl(this.changelogUrl); + }, + }); + } + + return menuOptions; + } + + private renderProgressContent(): TemplateResult { + return html` +
+

+ Downloading and applying the latest application release. + ${this.currentVersion && this.updatedVersion + ? html`Moving from ${this.currentVersion} to ${this.updatedVersion}.` + : this.updatedVersion + ? html`Preparing ${this.updatedVersion}.` + : ''} +

+

+ The updater advances automatically once the new build is installed and verified. +

+
+ `; + } + + private renderReadyContent(): TemplateResult { + const successDelaySeconds = this.getSuccessDelaySeconds(); + + return html` +
+

+ ${this.updatedVersion + ? html`Version ${this.updatedVersion} is ready to use.` + : 'The new version is ready to use.'} +

+

+ Configured next action: ${this.getSuccessActionDisplayLabel()}. It runs automatically in ${successDelaySeconds} seconds. +

+
+ `; + } + + private async runSuccessCountdown( + stepperArg: DeesStepper, + stepArg: IStep, + signal?: AbortSignal, + ): Promise { + const totalDuration = Math.max(1000, this.successDelayMs); + const startTime = Date.now(); + + while (!signal?.aborted) { + const elapsed = Math.min(totalDuration, Date.now() - startTime); + const remainingMilliseconds = Math.max(0, totalDuration - elapsed); + const remainingSeconds = Math.max(0, Math.ceil(remainingMilliseconds / 1000)); + + stepperArg.updateProgressStep( + { + label: remainingMilliseconds > 0 + ? this.getSuccessCountdownLabel(remainingSeconds) + : this.getSuccessActionNowLabel(), + percentage: (elapsed / totalDuration) * 100, + indeterminate: false, + showPercentage: false, + statusText: remainingMilliseconds > 0 + ? this.getSuccessCountdownStatus(remainingSeconds) + : this.getSuccessActionNowLabel(), + }, + stepArg, + ); + + if (remainingMilliseconds <= 0) { + break; + } + + const completed = await this.waitForCountdownTick(100, signal); + if (!completed) { + return; + } + } + + await this.runConfiguredSuccessAction(); + } + + private async waitForCountdownTick(timeoutArg: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + let completed = false; + + const finish = (result: boolean) => { + if (completed) { + return; + } + + completed = true; + if (signal) { + signal.removeEventListener('abort', handleAbort); + } + resolve(result); + }; + + const handleAbort = () => { + window.clearTimeout(timeoutId); + finish(false); + }; + + const timeoutId = window.setTimeout(() => { + finish(true); + }, timeoutArg); + + if (signal) { + signal.addEventListener('abort', handleAbort, { once: true }); + } + }); + } + + private async runConfiguredSuccessAction(): Promise { + if (this.onSuccessAction) { + await this.onSuccessAction(); + return; + } + + if (this.successAction === 'reload') { + await this.destroy(); + window.location.reload(); + return; + } + + await this.destroy(); + } + + private openExternalUrl(urlArg: string) { + window.open(urlArg, '_blank', 'noopener,noreferrer'); + } }