feat(ui): добавить кнопку установки при наличии обновления и исправить зависание окна установки
This commit is contained in:
@@ -509,6 +509,8 @@ async function applyCompletedComponentAction({
|
||||
}
|
||||
|
||||
const status = result.status || null;
|
||||
const hasUpdate =
|
||||
status === 'outdated' || status === 'outdated_same_release';
|
||||
|
||||
if (
|
||||
status === 'latest' ||
|
||||
@@ -535,7 +537,19 @@ async function applyCompletedComponentAction({
|
||||
if (notify) {
|
||||
showToast(getCheckToastMessage(status), 'success');
|
||||
}
|
||||
modalController?.completeSuccess(getCheckToastMessage(status));
|
||||
|
||||
if (hasUpdate) {
|
||||
const installButton = getComponentInstallAction(result.component);
|
||||
modalController?.completeSuccess(getCheckToastMessage(status), {
|
||||
autoCloseMs: 0,
|
||||
installText: installButton.text,
|
||||
onInstall: () => {
|
||||
void handleComponentAction(installButton);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
modalController?.completeSuccess(getCheckToastMessage(status));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -907,6 +921,31 @@ function getInstallAction(
|
||||
};
|
||||
}
|
||||
|
||||
function getComponentInstallKey(
|
||||
component: Tachyon.ComponentName,
|
||||
): UpdatesActionKey {
|
||||
switch (component) {
|
||||
case 'tachyon':
|
||||
return 'tachyonInstall';
|
||||
case 'sing_box':
|
||||
return 'singBoxInstall';
|
||||
case 'zapret':
|
||||
return 'zapretInstall';
|
||||
case 'zapret2':
|
||||
return 'zapret2Install';
|
||||
case 'byedpi':
|
||||
return 'byedpiInstall';
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentInstallAction(
|
||||
component: Tachyon.ComponentName,
|
||||
): ComponentActionButton {
|
||||
const isInstalled = !isNotInstalled(getComponentCurrentVersion(component));
|
||||
const key = getComponentInstallKey(component);
|
||||
return getInstallAction(component, key, isInstalled);
|
||||
}
|
||||
|
||||
function getInstalledUpdateActions(
|
||||
component: Tachyon.ComponentName,
|
||||
checkKey: UpdatesActionKey,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { showToast } from '../../../../helpers/showToast';
|
||||
import { renderCheckIcon24, renderXIcon24 } from '../../../../icons';
|
||||
import { renderButton } from '../../../../partials';
|
||||
import { Tachyon } from '../../../types';
|
||||
@@ -13,6 +12,13 @@ export interface UpdateProgressModalOptions {
|
||||
targetSha?: string;
|
||||
}
|
||||
|
||||
export interface CompleteSuccessOptions {
|
||||
autoCloseMs?: number;
|
||||
reloadPage?: boolean;
|
||||
onInstall?: () => void;
|
||||
installText?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgressModalController {
|
||||
updateStep: (stepIndex: number, statusText?: string) => void;
|
||||
updateStatus: (statusText: string) => void;
|
||||
@@ -22,10 +28,7 @@ export interface UpdateProgressModalController {
|
||||
currentSha?: string;
|
||||
targetSha?: string;
|
||||
}) => void;
|
||||
completeSuccess: (
|
||||
message?: string,
|
||||
options?: { autoCloseMs?: number; reloadPage?: boolean },
|
||||
) => void;
|
||||
completeSuccess: (message?: string, options?: CompleteSuccessOptions) => void;
|
||||
completeError: (errorMessage: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
@@ -338,10 +341,7 @@ export function showUpdateProgressModal(
|
||||
};
|
||||
titleBadgeEl.textContent = renderVersionBadgeText(currentModalVersions);
|
||||
},
|
||||
completeSuccess: (
|
||||
message?: string,
|
||||
opts?: { autoCloseMs?: number; reloadPage?: boolean },
|
||||
) => {
|
||||
completeSuccess: (message?: string, opts?: CompleteSuccessOptions) => {
|
||||
isCompleted = true;
|
||||
cleanupTimers();
|
||||
|
||||
@@ -397,6 +397,20 @@ export function showUpdateProgressModal(
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
const actionButtons: HTMLElement[] = [];
|
||||
|
||||
if (opts?.onInstall) {
|
||||
const installBtn = renderButton({
|
||||
classNames: ['cbi-button-save'],
|
||||
text: opts.installText || _('Install'),
|
||||
onClick: () => {
|
||||
controller.close();
|
||||
opts.onInstall!();
|
||||
},
|
||||
});
|
||||
actionButtons.push(installBtn);
|
||||
}
|
||||
|
||||
const closeBtn = renderButton({
|
||||
classNames: ['cbi-button-apply'],
|
||||
text: _('Close'),
|
||||
@@ -404,13 +418,22 @@ export function showUpdateProgressModal(
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
actionButtons.push(closeBtn);
|
||||
|
||||
actionButtonContainer.replaceChildren(closeBtn);
|
||||
actionButtonContainer.replaceChildren(...actionButtons);
|
||||
|
||||
const autoCloseMs = opts?.autoCloseMs ?? (isCheckAction ? 1000 : 2200);
|
||||
const defaultAutoCloseMs = opts?.onInstall
|
||||
? 0
|
||||
: isCheckAction
|
||||
? 1200
|
||||
: 1200;
|
||||
const autoCloseMs = opts?.autoCloseMs ?? defaultAutoCloseMs;
|
||||
if (autoCloseMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (activeModalController === controller) {
|
||||
if (
|
||||
activeModalController === controller ||
|
||||
activeModalController === null
|
||||
) {
|
||||
controller.close();
|
||||
}
|
||||
}, autoCloseMs);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MockMutationObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.MutationObserver = MockMutationObserver as any;
|
||||
|
||||
function createDummyElement(tag: string) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [] as any[],
|
||||
style: {} as Record<string, string>,
|
||||
setAttribute() {},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
replaceChildren: function (...nodes: any[]) {
|
||||
(this as any).children = nodes;
|
||||
},
|
||||
appendChild: function (node: any) {
|
||||
(this as any).children.push(node);
|
||||
},
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.document = {
|
||||
body: {} as any,
|
||||
createElement: (tag: string) => createDummyElement(tag) as any,
|
||||
createElementNS: (_ns: string, tag: string) => createDummyElement(tag) as any,
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
} as any;
|
||||
|
||||
(globalThis as any).rpc = { declare: vi.fn() };
|
||||
(globalThis as any).uci = { sections: vi.fn().mockResolvedValue([]) };
|
||||
(globalThis as any).localStorage = { getItem: vi.fn(), setItem: vi.fn() };
|
||||
|
||||
(globalThis as any).E = (tag: string, attrs?: any, children?: any) => {
|
||||
const el = (globalThis as any).document.createElement(tag);
|
||||
if (attrs) {
|
||||
const { style, ...rest } = attrs;
|
||||
Object.assign(el, rest);
|
||||
if (typeof style === 'string') {
|
||||
el.style.cssText = style;
|
||||
} else if (style && typeof style === 'object') {
|
||||
Object.assign(el.style, style);
|
||||
}
|
||||
}
|
||||
if (children) {
|
||||
el.children = Array.isArray(children) ? children : [children];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
const mockUi = {
|
||||
showModal: vi.fn(),
|
||||
hideModal: vi.fn(),
|
||||
};
|
||||
(globalThis as any).ui = mockUi;
|
||||
(globalThis as any)._ = (str: string) => str;
|
||||
|
||||
describe('renderUpdateProgressModal', () => {
|
||||
it('renders install button and does not auto-close when onInstall is provided', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockUi.showModal.mockReset();
|
||||
mockUi.hideModal.mockReset();
|
||||
|
||||
const { showUpdateProgressModal } = await import(
|
||||
'../partials/renderUpdateProgressModal'
|
||||
);
|
||||
|
||||
const onInstall = vi.fn();
|
||||
const controller = showUpdateProgressModal({
|
||||
component: 'sing_box',
|
||||
action: 'check_update',
|
||||
componentTitle: 'sing-box',
|
||||
});
|
||||
|
||||
controller.completeSuccess('Update is available', {
|
||||
autoCloseMs: 0,
|
||||
installText: 'Update',
|
||||
onInstall,
|
||||
});
|
||||
|
||||
// Verify modal element passed to showModal contains Install/Update button
|
||||
const modalContent = mockUi.showModal.mock.calls[0]?.[1] as any;
|
||||
expect(modalContent).toBeDefined();
|
||||
|
||||
// Advance time to verify auto-close did NOT fire
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(mockUi.hideModal).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('auto-closes after completed installation without hanging', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockUi.showModal.mockReset();
|
||||
mockUi.hideModal.mockReset();
|
||||
|
||||
const { showUpdateProgressModal } = await import(
|
||||
'../partials/renderUpdateProgressModal'
|
||||
);
|
||||
|
||||
const controller = showUpdateProgressModal({
|
||||
component: 'sing_box',
|
||||
action: 'install',
|
||||
componentTitle: 'sing-box',
|
||||
});
|
||||
|
||||
controller.completeSuccess('Update completed successfully!');
|
||||
|
||||
// Advance timer past autoCloseMs (1200ms)
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(mockUi.hideModal).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -15387,6 +15387,18 @@ function showUpdateProgressModal(options) {
|
||||
}
|
||||
}, 1e3);
|
||||
} else {
|
||||
const actionButtons = [];
|
||||
if (opts?.onInstall) {
|
||||
const installBtn = renderButton({
|
||||
classNames: ["cbi-button-save"],
|
||||
text: opts.installText || _("Install"),
|
||||
onClick: () => {
|
||||
controller.close();
|
||||
opts.onInstall();
|
||||
}
|
||||
});
|
||||
actionButtons.push(installBtn);
|
||||
}
|
||||
const closeBtn = renderButton({
|
||||
classNames: ["cbi-button-apply"],
|
||||
text: _("Close"),
|
||||
@@ -15394,11 +15406,13 @@ function showUpdateProgressModal(options) {
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
actionButtonContainer.replaceChildren(closeBtn);
|
||||
const autoCloseMs = opts?.autoCloseMs ?? (isCheckAction ? 1e3 : 2200);
|
||||
actionButtons.push(closeBtn);
|
||||
actionButtonContainer.replaceChildren(...actionButtons);
|
||||
const defaultAutoCloseMs = opts?.onInstall ? 0 : isCheckAction ? 1200 : 1200;
|
||||
const autoCloseMs = opts?.autoCloseMs ?? defaultAutoCloseMs;
|
||||
if (autoCloseMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (activeModalController === controller) {
|
||||
if (activeModalController === controller || activeModalController === null) {
|
||||
controller.close();
|
||||
}
|
||||
}, autoCloseMs);
|
||||
@@ -15760,6 +15774,7 @@ async function applyCompletedComponentAction({
|
||||
preserveCheckResultsOnNextMount = true;
|
||||
}
|
||||
const status = result.status || null;
|
||||
const hasUpdate = status === "outdated" || status === "outdated_same_release";
|
||||
if (status === "latest" || status === "outdated" || status === "dev" || status === "outdated_same_release") {
|
||||
setCheckResult(
|
||||
result.component,
|
||||
@@ -15779,7 +15794,18 @@ async function applyCompletedComponentAction({
|
||||
if (notify) {
|
||||
showToast(getCheckToastMessage(status), "success");
|
||||
}
|
||||
modalController?.completeSuccess(getCheckToastMessage(status));
|
||||
if (hasUpdate) {
|
||||
const installButton = getComponentInstallAction(result.component);
|
||||
modalController?.completeSuccess(getCheckToastMessage(status), {
|
||||
autoCloseMs: 0,
|
||||
installText: installButton.text,
|
||||
onInstall: () => {
|
||||
void handleComponentAction(installButton);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
modalController?.completeSuccess(getCheckToastMessage(status));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.action === "install" || result.action === "reinstall" || result.action.startsWith("install_")) {
|
||||
@@ -16067,6 +16093,25 @@ function getInstallAction(component, key, installed) {
|
||||
action: "install"
|
||||
};
|
||||
}
|
||||
function getComponentInstallKey(component) {
|
||||
switch (component) {
|
||||
case "tachyon":
|
||||
return "tachyonInstall";
|
||||
case "sing_box":
|
||||
return "singBoxInstall";
|
||||
case "zapret":
|
||||
return "zapretInstall";
|
||||
case "zapret2":
|
||||
return "zapret2Install";
|
||||
case "byedpi":
|
||||
return "byedpiInstall";
|
||||
}
|
||||
}
|
||||
function getComponentInstallAction(component) {
|
||||
const isInstalled = !isNotInstalled(getComponentCurrentVersion(component));
|
||||
const key = getComponentInstallKey(component);
|
||||
return getInstallAction(component, key, isInstalled);
|
||||
}
|
||||
function getInstalledUpdateActions(component, checkKey, installKey, installed = true) {
|
||||
if (!installed) {
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user