feat(accounts): add multi-selection and batch removal
Support Windows-style single and Ctrl/Cmd additive row selection, clear selections with Escape, and show the selected count on the removal action. Remove selected accounts behind one confirmation, strengthen the selected-row treatment, and delete the redundant global account activation switch so individual account toggles remain authoritative.
This commit is contained in:
+53
-49
@@ -40,7 +40,7 @@ import {
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate } from "./account-ui";
|
||||
import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate, updateAccountRowSelection } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||
@@ -1583,7 +1583,7 @@ export function App(): ReactElement {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
|
||||
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview");
|
||||
const [selectedAccountRowKey, setSelectedAccountRowKey] = useState<string | null>(null);
|
||||
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
|
||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
@@ -2398,7 +2398,14 @@ export function App(): ReactElement {
|
||||
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAccountRowKey((current) => pruneAccountRowSelection(current, accountRows.map((row) => row.rowKey)));
|
||||
const existingRowKeys = accountRows.map((row) => row.rowKey);
|
||||
setSelectedAccountRowKeys((current) => {
|
||||
const next = pruneAccountRowSelections([...current], existingRowKeys);
|
||||
if (next.length === current.size && next.every((rowKey) => current.has(rowKey))) {
|
||||
return current;
|
||||
}
|
||||
return new Set(next);
|
||||
});
|
||||
}, [accountRows]);
|
||||
const availableAccountOptions = useMemo(() => (
|
||||
ACCOUNT_OPTIONS.filter((option) => option.kind === "megadebrid-api"
|
||||
@@ -2938,36 +2945,21 @@ export function App(): ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const setAllAccountsEnabled = async (enabled: boolean): Promise<void> => {
|
||||
await performQuickAction(async () => {
|
||||
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
|
||||
const nextEnabledState = buildBulkAccountEnabledState(
|
||||
settingsDraft.disabledProviders || [],
|
||||
configuredProviderIds,
|
||||
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
|
||||
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
|
||||
enabled
|
||||
);
|
||||
const nextDraft: RendererSettingsDraft = {
|
||||
...settingsDraft,
|
||||
...nextEnabledState,
|
||||
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
|
||||
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
|
||||
};
|
||||
await persistAccountToggle(nextDraft);
|
||||
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200);
|
||||
}, (error) => {
|
||||
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200);
|
||||
});
|
||||
};
|
||||
|
||||
const removeAccountTableRow = (row: AccountTableRow): void => {
|
||||
const removeAccountTableRows = (rows: readonly AccountTableRow[]): void => {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
setAccountContextMenu(null);
|
||||
void (async () => {
|
||||
const username = resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined);
|
||||
const row = rows[0];
|
||||
const username = rows.length === 1
|
||||
? resolveAccountUsername(row.username, row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId]?.email : undefined)
|
||||
: "—";
|
||||
const confirmed = await askConfirmPrompt({
|
||||
title: `${row.hosterLabel} entfernen`,
|
||||
message: `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`,
|
||||
title: rows.length === 1 ? `${row.hosterLabel} entfernen` : `${rows.length} Accounts entfernen`,
|
||||
message: rows.length === 1
|
||||
? `Soll ${row.hosterLabel}${username !== "—" ? ` (${username})` : ""} wirklich entfernt werden?`
|
||||
: `Sollen die ausgewählten ${rows.length} Accounts wirklich entfernt werden?`,
|
||||
confirmLabel: "Entfernen",
|
||||
danger: true
|
||||
});
|
||||
@@ -2975,19 +2967,25 @@ export function App(): ReactElement {
|
||||
return;
|
||||
}
|
||||
await performQuickAction(async () => {
|
||||
const result = await window.rd.deleteAccount(buildAccountDeleteCommand(row.editTarget));
|
||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||
applyPersistedSettings(result.settings);
|
||||
if (row.entry.service === "alldebrid") {
|
||||
setAllDebridHostInfo(null);
|
||||
for (const selectedRow of rows) {
|
||||
const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget));
|
||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||
applyPersistedSettings(result.settings);
|
||||
if (selectedRow.entry.service === "alldebrid") {
|
||||
setAllDebridHostInfo(null);
|
||||
}
|
||||
}
|
||||
showToast(`${row.hosterLabel} entfernt`, 2200);
|
||||
const removedRowKeys = new Set(rows.map((selectedRow) => selectedRow.rowKey));
|
||||
setSelectedAccountRowKeys((current) => new Set([...current].filter((rowKey) => !removedRowKeys.has(rowKey))));
|
||||
showToast(rows.length === 1 ? `${row.hosterLabel} entfernt` : `${rows.length} Accounts entfernt`, 2200);
|
||||
}, (error) => {
|
||||
showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200);
|
||||
showToast(`${rows.length === 1 ? "Account" : "Accounts"} konnte${rows.length === 1 ? "" : "n"} nicht entfernt werden: ${String(error)}`, 3200);
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
const removeAccountTableRow = (row: AccountTableRow): void => removeAccountTableRows([row]);
|
||||
|
||||
const checkAccountTableRow = (row: AccountTableRow): void => {
|
||||
setAccountContextMenu(null);
|
||||
if (row.checkable) {
|
||||
@@ -4899,15 +4897,14 @@ export function App(): ReactElement {
|
||||
canCheck: row.checkable
|
||||
};
|
||||
}), [accountRows, snapshot.settings.debridAccountStatuses]);
|
||||
const selectedAccountViewId = useMemo(() => {
|
||||
const selectedRow = selectedAccountRowKey ? accountRows.find((row) => row.rowKey === selectedAccountRowKey) : null;
|
||||
return selectedRow ? accountRowViewId(selectedRow) : null;
|
||||
}, [accountRows, selectedAccountRowKey]);
|
||||
const selectedAccountViewIds = useMemo(() => accountRows
|
||||
.filter((row) => selectedAccountRowKeys.has(row.rowKey))
|
||||
.map(accountRowViewId), [accountRows, selectedAccountRowKeys]);
|
||||
const projectedAccountRows = useMemo(() => projectAccountRows(
|
||||
accountSources,
|
||||
selectedAccountViewId ? [selectedAccountViewId] : [],
|
||||
selectedAccountViewIds,
|
||||
runtimeNow
|
||||
), [accountSources, runtimeNow, selectedAccountViewId]);
|
||||
), [accountSources, runtimeNow, selectedAccountViewIds]);
|
||||
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
|
||||
? projectedAccountRows
|
||||
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]);
|
||||
@@ -4920,9 +4917,8 @@ export function App(): ReactElement {
|
||||
const accountWorkspaceModel: AccountWorkspaceViewModel = {
|
||||
activePanel: accountManagementTab,
|
||||
rows: visibleAccountRows,
|
||||
selectedIds: selectedAccountViewId ? [selectedAccountViewId] : [],
|
||||
selectedIds: selectedAccountViewIds,
|
||||
busy: actionBusy || accountCheckBusy,
|
||||
allEnabled: accountRows.length > 0 && accountRows.some((row) => !row.disabled),
|
||||
statusSort: accountStatusSort,
|
||||
rules: {
|
||||
providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)),
|
||||
@@ -4956,7 +4952,14 @@ export function App(): ReactElement {
|
||||
};
|
||||
const accountWorkspaceActions: AccountWorkspaceActions = {
|
||||
onPanelChange: setAccountManagementTab,
|
||||
onSelect: (rowId) => setSelectedAccountRowKey(accountRowBindings.get(rowId)?.rowKey ?? null),
|
||||
onSelect: (rowId, additive) => {
|
||||
const rowKey = accountRowBindings.get(rowId)?.rowKey;
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
setSelectedAccountRowKeys((current) => new Set(updateAccountRowSelection([...current], rowKey, additive)));
|
||||
},
|
||||
onClearSelection: () => setSelectedAccountRowKeys(new Set()),
|
||||
onToggleEnabled: (rowId) => {
|
||||
const row = accountRowBindings.get(rowId);
|
||||
if (row) toggleAccountTableRow(row);
|
||||
@@ -4976,12 +4979,13 @@ export function App(): ReactElement {
|
||||
},
|
||||
onAdd: openCreateAccountDialog,
|
||||
onRemoveSelected: () => {
|
||||
const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null;
|
||||
if (row) removeAccountTableRow(row);
|
||||
const rows = selectedAccountViewIds
|
||||
.map((rowId) => accountRowBindings.get(rowId))
|
||||
.filter((row): row is AccountTableRow => Boolean(row));
|
||||
removeAccountTableRows(rows);
|
||||
},
|
||||
onCheckActive: () => { void checkAccounts("active"); },
|
||||
onCheckAll: () => { void checkAccounts("all"); },
|
||||
onSetAllEnabled: (enabled) => { void setAllAccountsEnabled(enabled); },
|
||||
onStatusSort: cycleAccountStatusSort,
|
||||
onMoveProvider: (index, direction) => {
|
||||
const target = index + direction;
|
||||
|
||||
+18
-21
@@ -36,6 +36,24 @@ export function pruneAccountRowSelection(selectedRowKey: string | null, existing
|
||||
return selectedRowKey && existingRowKeys.includes(selectedRowKey) ? selectedRowKey : null;
|
||||
}
|
||||
|
||||
export function updateAccountRowSelection(selectedRowKeys: readonly string[], rowKey: string, additive: boolean): string[] {
|
||||
if (!additive) {
|
||||
return [rowKey];
|
||||
}
|
||||
const next = new Set(selectedRowKeys);
|
||||
if (next.has(rowKey)) {
|
||||
next.delete(rowKey);
|
||||
} else {
|
||||
next.add(rowKey);
|
||||
}
|
||||
return [...next];
|
||||
}
|
||||
|
||||
export function pruneAccountRowSelections(selectedRowKeys: readonly string[], existingRowKeys: readonly string[]): string[] {
|
||||
const existing = new Set(existingRowKeys);
|
||||
return [...new Set(selectedRowKeys)].filter((rowKey) => existing.has(rowKey));
|
||||
}
|
||||
|
||||
export function resolveVisibleAccountKind<T extends string>(currentKind: T | null, visibleKinds: readonly T[]): T | null {
|
||||
return currentKind && visibleKinds.includes(currentKind) ? currentKind : visibleKinds[0] ?? null;
|
||||
}
|
||||
@@ -106,24 +124,3 @@ export function buildScopedAccountEnabledState(
|
||||
: [...new Set([...currentDisabledAccountIds, accountId])]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBulkAccountEnabledState(
|
||||
currentDisabledProviders: DebridProvider[],
|
||||
configuredProviders: DebridProvider[],
|
||||
megaAccountIds: string[],
|
||||
debridLinkKeyIds: string[],
|
||||
enabled: boolean
|
||||
): {
|
||||
disabledProviders: DebridProvider[];
|
||||
megaDebridDisabledAccountIds: string[];
|
||||
debridLinkDisabledKeyIds: string[];
|
||||
} {
|
||||
const configured = new Set(configuredProviders);
|
||||
return {
|
||||
disabledProviders: enabled
|
||||
? currentDisabledProviders.filter((provider) => !configured.has(provider))
|
||||
: [...new Set([...currentDisabledProviders, ...configuredProviders])],
|
||||
megaDebridDisabledAccountIds: enabled ? [] : [...new Set(megaAccountIds)],
|
||||
debridLinkDisabledKeyIds: enabled ? [] : [...new Set(debridLinkKeyIds)]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,14 +101,14 @@ export interface AccountWorkspaceViewModel {
|
||||
selectedIds: readonly string[];
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
allEnabled?: boolean;
|
||||
statusSort?: "none" | "desc" | "asc";
|
||||
rules: AccountRulesViewModel;
|
||||
}
|
||||
|
||||
export interface AccountWorkspaceActions {
|
||||
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
||||
onSelect: (rowId: string) => void;
|
||||
onSelect: (rowId: string, additive: boolean) => void;
|
||||
onClearSelection: () => void;
|
||||
onToggleEnabled: (rowId: string) => void;
|
||||
onEdit: (rowId: string) => void;
|
||||
onContextMenu: (rowId: string, x: number, y: number) => void;
|
||||
@@ -117,7 +117,6 @@ export interface AccountWorkspaceActions {
|
||||
onRemoveSelected: () => void;
|
||||
onCheckActive: () => void;
|
||||
onCheckAll: () => void;
|
||||
onSetAllEnabled?: (enabled: boolean) => void;
|
||||
onStatusSort?: () => void;
|
||||
onMoveProvider?: (index: number, direction: -1 | 1) => void;
|
||||
onProviderDragStart?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
@@ -302,14 +301,14 @@ function AccountRow({
|
||||
gridTemplateColumns: string;
|
||||
minWidth: number;
|
||||
}): ReactElement {
|
||||
const selectRow = (): void => actions.onSelect(row.id);
|
||||
const onClick = (): void => selectRow();
|
||||
const selectRow = (additive = false): void => actions.onSelect(row.id, additive);
|
||||
const onClick = (event: MouseEvent<HTMLDivElement>): void => selectRow(event.ctrlKey || event.metaKey);
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
selectRow();
|
||||
selectRow(event.ctrlKey || event.metaKey);
|
||||
};
|
||||
const openContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||
event.preventDefault();
|
||||
@@ -455,7 +454,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
||||
<div className="settings-account-local-actions">
|
||||
<div>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onAdd} type="button">+ Hinzufügen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">− Entfernen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">− Entfernen{model.selectedIds.length > 1 ? ` (${model.selectedIds.length})` : ""}</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || !model.rows.some((row) => row.enabled && row.canCheck)} onClick={actions.onCheckActive} title="Prüft nur aktivierte Accounts." type="button">↻ Aktive aktualisieren</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckAll} title="Prüft alle angelegten Accounts, auch deaktivierte." type="button">↻ Alle aktualisieren</button>
|
||||
</div>
|
||||
@@ -584,7 +583,16 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
|
||||
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-workspace">
|
||||
<div
|
||||
className="settings-account-workspace"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || model.selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
actions.onClearSelection();
|
||||
}}
|
||||
>
|
||||
{model.busy ? <span aria-live="polite" className="settings-visually-hidden" role="status">Accountdaten werden aktualisiert.</span> : null}
|
||||
{model.error ? <span className="settings-visually-hidden" role="alert">{model.error}</span> : null}
|
||||
<header className="settings-account-heading">
|
||||
@@ -592,17 +600,6 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
||||
<h2>Accountverwaltung</h2>
|
||||
<p>Accounts hinzufügen, prüfen und verwalten.</p>
|
||||
</div>
|
||||
{typeof model.allEnabled === "boolean" && actions.onSetAllEnabled ? (
|
||||
<label className="settings-rule-toggle settings-account-all-enabled">
|
||||
<input
|
||||
checked={model.allEnabled}
|
||||
disabled={model.busy || model.rows.length === 0}
|
||||
onChange={(event) => actions.onSetAllEnabled?.(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Accounts zum Herunterladen verwenden</span>
|
||||
</label>
|
||||
) : null}
|
||||
</header>
|
||||
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" aria-orientation="horizontal" axis="horizontal" className="settings-account-tabs" role="tablist">
|
||||
{ACCOUNT_WORKSPACE_PANELS.map((panel, index) => (
|
||||
|
||||
@@ -647,13 +647,22 @@
|
||||
}
|
||||
|
||||
.settings-account-row.is-selected {
|
||||
background: var(--ui-active);
|
||||
background: color-mix(in srgb, #5b8cff 22%, var(--ui-panel));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, #5b8cff 58%, transparent);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-account-row.is-selected::before {
|
||||
width: 4px;
|
||||
background: #5b8cff;
|
||||
box-shadow: 0 0 10px color-mix(in srgb, #5b8cff 72%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-account-row.is-selected.is-disabled {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.settings-account-row.is-disabled {
|
||||
color: var(--ui-text-muted);
|
||||
opacity: 0.72;
|
||||
|
||||
+17
-30
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBulkAccountEnabledState,
|
||||
buildConfiguredProviderOrder,
|
||||
getAccountDialogSelectableOptions,
|
||||
isAccountRowSelectionKey,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
resolveAccountUsername,
|
||||
resolveVisibleAccountKind
|
||||
} from "../src/renderer/account-ui";
|
||||
import * as accountUi from "../src/renderer/account-ui";
|
||||
|
||||
describe("account mode filter", () => {
|
||||
it("shows only API options for the API filter", () => {
|
||||
@@ -22,35 +22,7 @@ describe("account mode filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk account activation", () => {
|
||||
it("disables each configured provider and account exactly once", () => {
|
||||
expect(buildBulkAccountEnabledState(
|
||||
["alldebrid"],
|
||||
["megadebrid-api", "alldebrid"],
|
||||
["mega-1", "mega-1", "mega-2"],
|
||||
["debrid-link-1", "debrid-link-1"],
|
||||
false
|
||||
)).toEqual({
|
||||
disabledProviders: ["alldebrid", "megadebrid-api"],
|
||||
megaDebridDisabledAccountIds: ["mega-1", "mega-2"],
|
||||
debridLinkDisabledKeyIds: ["debrid-link-1"]
|
||||
});
|
||||
});
|
||||
|
||||
it("enables configured providers without changing unrelated provider state", () => {
|
||||
expect(buildBulkAccountEnabledState(
|
||||
["realdebrid", "alldebrid", "megadebrid-api"],
|
||||
["alldebrid", "megadebrid-api"],
|
||||
["mega-1"],
|
||||
["debrid-link-1"],
|
||||
true
|
||||
)).toEqual({
|
||||
disabledProviders: ["realdebrid"],
|
||||
megaDebridDisabledAccountIds: [],
|
||||
debridLinkDisabledKeyIds: []
|
||||
});
|
||||
});
|
||||
|
||||
describe("account provider order", () => {
|
||||
it("preserves the custom provider order while accounts are disabled", () => {
|
||||
expect(buildConfiguredProviderOrder(
|
||||
["debridlink", "realdebrid", "alldebrid"],
|
||||
@@ -60,6 +32,21 @@ describe("bulk account activation", () => {
|
||||
});
|
||||
|
||||
describe("account selection", () => {
|
||||
it("replaces a single selection and toggles rows only for additive selection", () => {
|
||||
const api = accountUi as typeof accountUi & {
|
||||
updateAccountRowSelection?: (selected: readonly string[], rowKey: string, additive: boolean) => string[];
|
||||
pruneAccountRowSelections?: (selected: readonly string[], existing: readonly string[]) => string[];
|
||||
};
|
||||
|
||||
expect(api.updateAccountRowSelection).toBeTypeOf("function");
|
||||
expect(api.pruneAccountRowSelections).toBeTypeOf("function");
|
||||
expect(api.updateAccountRowSelection?.(["account-a"], "account-b", false)).toEqual(["account-b"]);
|
||||
expect(api.updateAccountRowSelection?.(["account-a"], "account-b", true)).toEqual(["account-a", "account-b"]);
|
||||
expect(api.updateAccountRowSelection?.(["account-a", "account-b"], "account-a", true)).toEqual(["account-b"]);
|
||||
expect(api.pruneAccountRowSelections?.(["account-a", "missing", "account-a"], ["account-a", "account-b"]))
|
||||
.toEqual(["account-a"]);
|
||||
});
|
||||
|
||||
it("clears a selected row after that account disappears", () => {
|
||||
expect(pruneAccountRowSelection("account-a", ["account-b"])).toBeNull();
|
||||
expect(pruneAccountRowSelection("account-a", ["account-a", "account-b"])).toBe("account-a");
|
||||
|
||||
@@ -8,7 +8,6 @@ import { buildAccountAddFields, createAccountDialogState } from "../src/renderer
|
||||
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||
import {
|
||||
buildScopedAccountEnabledState,
|
||||
buildBulkAccountEnabledState,
|
||||
buildConfiguredProviderOrder,
|
||||
resolveAccountStatusState,
|
||||
runOptimisticAccountUpdate
|
||||
@@ -302,6 +301,7 @@ function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): Acc
|
||||
return {
|
||||
onPanelChange: () => {},
|
||||
onSelect: () => {},
|
||||
onClearSelection: () => {},
|
||||
onToggleEnabled: () => {},
|
||||
onEdit: () => {},
|
||||
onContextMenu: () => {},
|
||||
@@ -397,22 +397,11 @@ describe("settings model", () => {
|
||||
expect(buildTargetedAccountCheck(options[1], "ddownload-new")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves provider order and deduplicates bulk account identities", () => {
|
||||
it("preserves provider order", () => {
|
||||
expect(buildConfiguredProviderOrder(
|
||||
["debridlink", "realdebrid", "alldebrid"],
|
||||
["realdebrid", "alldebrid", "debridlink", "bestdebrid"]
|
||||
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
|
||||
expect(buildBulkAccountEnabledState(
|
||||
["alldebrid"],
|
||||
["megadebrid-api", "alldebrid"],
|
||||
["mega-1", "mega-1"],
|
||||
["dl-1", "dl-1"],
|
||||
false
|
||||
)).toEqual({
|
||||
disabledProviders: ["alldebrid", "megadebrid-api"],
|
||||
megaDebridDisabledAccountIds: ["mega-1"],
|
||||
debridLinkDisabledKeyIds: ["dl-1"]
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exact rounded limits and migrates edited identity metadata", () => {
|
||||
@@ -781,7 +770,7 @@ describe("account workspace", () => {
|
||||
const tree = AccountWorkspace({
|
||||
model: workspaceModel(),
|
||||
actions: workspaceActions({
|
||||
onSelect: (id) => calls.push(`select:${id}`),
|
||||
onSelect: (id, additive) => calls.push(`select:${id}:${additive}`),
|
||||
onToggleEnabled: (id) => calls.push(`toggle:${id}`),
|
||||
onEdit: (id) => calls.push(`edit:${id}`),
|
||||
onContextMenu: (id) => calls.push(`context:${id}`)
|
||||
@@ -792,7 +781,8 @@ describe("account workspace", () => {
|
||||
const actionButton = findElement(row, (element) => element.type === "button" && String(element.props["aria-label"] || "").includes("Aktionen"));
|
||||
const rowId = workspaceModel().rows[0].id;
|
||||
|
||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row });
|
||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: false, metaKey: false });
|
||||
row.props.onClick({ target: { role: "cell" }, currentTarget: row, ctrlKey: true, metaKey: false });
|
||||
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
|
||||
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
|
||||
checkbox.props.onChange();
|
||||
@@ -800,14 +790,43 @@ describe("account workspace", () => {
|
||||
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
|
||||
|
||||
expect(calls).toEqual([
|
||||
`select:${rowId}`,
|
||||
`select:${rowId}`,
|
||||
`select:${rowId}:false`,
|
||||
`select:${rowId}:true`,
|
||||
`select:${rowId}:false`,
|
||||
`toggle:${rowId}`,
|
||||
`edit:${rowId}`,
|
||||
`context:${rowId}`
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the selected account count and clears the selection with Escape", () => {
|
||||
let cleared = 0;
|
||||
const model = workspaceModel();
|
||||
const tree = AccountWorkspace({
|
||||
actions: workspaceActions({ onClearSelection: () => { cleared += 1; } }),
|
||||
model: { ...model, selectedIds: model.rows.slice(0, 3).map((row) => row.id) }
|
||||
});
|
||||
const html = renderToStaticMarkup(tree);
|
||||
|
||||
expect(html).toContain("− Entfernen (3)");
|
||||
tree.props.onKeyDown({ key: "Escape", preventDefault: () => {} });
|
||||
expect(cleared).toBe(1);
|
||||
});
|
||||
|
||||
it("removes the redundant global account activation switch", () => {
|
||||
const legacyActions = { ...workspaceActions(), onSetAllEnabled: () => {} } as AccountWorkspaceActions;
|
||||
const legacyModel = { ...workspaceModel(), allEnabled: true } as AccountWorkspaceViewModel;
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountWorkspace
|
||||
actions={legacyActions}
|
||||
model={legacyModel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Accounts zum Herunterladen verwenden");
|
||||
expect(settingsCss).toMatch(/\.settings-account-row\.is-selected\s*{[^}]*#5b8cff[^}]*box-shadow:/s);
|
||||
});
|
||||
|
||||
it("copies only populated username and email cells without triggering the row", () => {
|
||||
const copies: string[] = [];
|
||||
const tree = AccountWorkspace({
|
||||
|
||||
Reference in New Issue
Block a user