feat(stepper,updater): add progress-aware stepper flows and updater countdown states

This commit is contained in:
2026-04-16 14:21:16 +00:00
parent 2f4c47f0d2
commit 428d2741d1
7 changed files with 806 additions and 141 deletions
@@ -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);
}
const waitForDemoStep = async (timeoutArg: number): Promise<void> => {
await new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), timeoutArg);
});
};
export const demoFunc = () => {
let updaterRunning = false;
return html`
<div style="display: grid; gap: 16px; place-items: center; padding: 32px; text-align: center;">
<p style="margin: 0; max-width: 540px; line-height: 1.6; color: var(--dees-color-text-secondary);">
Launches the updater as a stepper flow. The first step streams terminal-style
progress updates and then moves automatically to the ready step.
</p>
<dees-button
@click=${async () => {
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</dees-button>
</div>
`;
};
@@ -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> | 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<void> | null = null;
private onSuccessAction: (() => Promise<void> | 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`
<dees-windowlayer
@clicked="${this.windowLayerClicked}"
.options=${{
blur: true,
}}
>
<div class="modalContainer">
<div class="headingContainer">
<dees-spinner .size=${60}></dees-spinner>
<h1>Updating the application...</h1>
</div>
<div class="progress">
<dees-progressbar .progress=${0.5}></dees-progressbar>
</div>
<div class="buttonContainer">
<dees-button>More info</dees-button>
<dees-button>Changelog</dees-button>
</div>
</div> </dees-windowlayer
>>
`;
return html``;
}
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
void this.show();
}
public async show(): Promise<void> {
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<IStepProgressState>) {
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<void> }> = [];
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`
<div style="display: grid; gap: 12px; color: var(--dees-color-text-secondary); line-height: 1.6;">
<p style="margin: 0; text-align: center;">
Downloading and applying the latest application release.
${this.currentVersion && this.updatedVersion
? html`Moving from <strong>${this.currentVersion}</strong> to <strong>${this.updatedVersion}</strong>.`
: this.updatedVersion
? html`Preparing <strong>${this.updatedVersion}</strong>.`
: ''}
</p>
<p style="margin: 0; text-align: center; font-size: 13px; color: var(--dees-color-text-muted);">
The updater advances automatically once the new build is installed and verified.
</p>
</div>
`;
}
private renderReadyContent(): TemplateResult {
const successDelaySeconds = this.getSuccessDelaySeconds();
return html`
<div style="display: grid; gap: 12px; color: var(--dees-color-text-secondary); line-height: 1.6;">
<p style="margin: 0; text-align: center;">
${this.updatedVersion
? html`Version <strong>${this.updatedVersion}</strong> is ready to use.`
: 'The new version is ready to use.'}
</p>
<p style="margin: 0; text-align: center; font-size: 13px; color: var(--dees-color-text-muted);">
Configured next action: ${this.getSuccessActionDisplayLabel()}. It runs automatically in ${successDelaySeconds} seconds.
</p>
</div>
`;
}
private async runSuccessCountdown(
stepperArg: DeesStepper,
stepArg: IStep,
signal?: AbortSignal,
): Promise<void> {
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<boolean> {
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<void> {
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');
}
}