feat(stepper,updater): add progress-aware stepper flows and updater countdown states
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user