feat(accounts): add precise service filtering
Add an explicit service selector to the account creation dialog while keeping the text field focused on access types such as API and web login. Preserve the result area dimensions during filtering and allow Enter to choose the first visible account type. Cover filtering, keyboard selection, translations, and stable list geometry with focused regression tests.
This commit is contained in:
+12
-28
@@ -40,8 +40,7 @@ import {
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
||||
import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate, updateAccountRowSelection } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
import { buildConfiguredProviderOrder, buildScopedAccountEnabledState, filterAccountDialogOptions, getAccountDialogSelectableOptions, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, runOptimisticAccountUpdate, updateAccountRowSelection } from "./account-ui";
|
||||
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||
@@ -1614,7 +1613,7 @@ export function App(): ReactElement {
|
||||
const [accountEditSecretVisible, setAccountEditSecretVisible] = useState<Record<string, boolean>>({});
|
||||
const [accountEditSecretBusy, setAccountEditSecretBusy] = useState<string | null>(null);
|
||||
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||
const [accountDialogModeFilter, setAccountDialogModeFilter] = useState<AccountModeFilter>("all");
|
||||
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
||||
const [keyStatsPopup, setKeyStatsPopup] = useState<string | null>(null);
|
||||
const [debridLinkHostLimits, setDebridLinkHostLimits] = useState<Record<string, DebridLinkHostLimitInfo>>({});
|
||||
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
|
||||
@@ -2432,28 +2431,12 @@ export function App(): ReactElement {
|
||||
accountDialog.service
|
||||
);
|
||||
}, [accountDialog, availableAccountOptions]);
|
||||
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
|
||||
const accountDialogServiceFilters = useMemo(() => (
|
||||
[...new Set(accountDialogSelectableOptions.map((option) => option.serviceLabel))]
|
||||
), [accountDialogSelectableOptions]);
|
||||
const filteredAccountDialogOptions = useMemo(() => (
|
||||
accountDialogSelectableOptions.filter((option) => {
|
||||
const matchesMode = accountDialogModeFilter === "all"
|
||||
|| (accountDialogModeFilter === "api" && option.modeLabel === "API")
|
||||
|| (accountDialogModeFilter === "web" && (option.modeLabel.startsWith("Web") || option.kind === "ddownload-login" || option.kind === "linksnappy-login"));
|
||||
if (!matchesMode) {
|
||||
return false;
|
||||
}
|
||||
if (!accountDialogSearchQuery) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
option.title,
|
||||
option.serviceLabel,
|
||||
option.modeLabel,
|
||||
option.pickerDescription,
|
||||
getAccountPickerFunctionLabel(option)
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(accountDialogSearchQuery);
|
||||
})
|
||||
), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]);
|
||||
filterAccountDialogOptions(accountDialogSelectableOptions, accountDialogSearch, accountDialogServiceFilter)
|
||||
), [accountDialogSearch, accountDialogSelectableOptions, accountDialogServiceFilter]);
|
||||
const handleUpdateResult = async (
|
||||
result: UpdateCheckResult,
|
||||
source: "manual" | "startup",
|
||||
@@ -2686,7 +2669,7 @@ export function App(): ReactElement {
|
||||
|
||||
const openCreateAccountDialog = (): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialogModeFilter("all");
|
||||
setAccountDialogServiceFilter("all");
|
||||
setAccountDialog(createAccountDialogState("create", availableAccountOptions[0]?.kind ?? null, settingsDraft));
|
||||
};
|
||||
|
||||
@@ -2725,7 +2708,7 @@ export function App(): ReactElement {
|
||||
const closeAccountDialog = useCallback((): void => {
|
||||
setAccountDialog(null);
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialogModeFilter("all");
|
||||
setAccountDialogServiceFilter("all");
|
||||
}, []);
|
||||
|
||||
const closeAccountEditDialog = useCallback((): void => {
|
||||
@@ -5186,7 +5169,7 @@ export function App(): ReactElement {
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
onQueryChange: setAccountDialogSearch,
|
||||
onFilterChange: setAccountDialogModeFilter,
|
||||
onServiceFilterChange: setAccountDialogServiceFilter,
|
||||
onOptionSelect: (optionId) => updateAccountDialogKind(optionId as AccountKind),
|
||||
onFieldChange: (fieldId, value) => setAccountDialog((current) => current ? { ...current, [fieldId]: value } : current),
|
||||
onClose: closeAccountDialog,
|
||||
@@ -5198,7 +5181,8 @@ export function App(): ReactElement {
|
||||
model={{
|
||||
open: Boolean(accountDialog),
|
||||
query: accountDialogSearch,
|
||||
filter: accountDialogModeFilter,
|
||||
serviceFilter: accountDialogServiceFilter,
|
||||
serviceFilters: accountDialogServiceFilters,
|
||||
options: accountAddOptions,
|
||||
selectedOptionId: accountDialog?.kind ?? null,
|
||||
fields: accountAddFields,
|
||||
|
||||
@@ -16,6 +16,27 @@ export function matchesAccountModeFilter(option: AccountModeOption, filter: Acco
|
||||
return option.modeLabel.startsWith("Web");
|
||||
}
|
||||
|
||||
export function filterAccountDialogOptions<T extends {
|
||||
serviceLabel: string;
|
||||
title: string;
|
||||
modeLabel: string;
|
||||
pickerDescription: string;
|
||||
}>(options: readonly T[], query: string, serviceFilter: string): T[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de-DE");
|
||||
return options.filter((option) => {
|
||||
if (serviceFilter !== "all" && option.serviceLabel !== serviceFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return [option.title, option.modeLabel, option.pickerDescription]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildConfiguredProviderOrder(
|
||||
currentOrder: readonly DebridProvider[],
|
||||
configuredProviders: readonly DebridProvider[]
|
||||
|
||||
@@ -75,7 +75,7 @@ const pairs = [
|
||||
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
|
||||
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
|
||||
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
|
||||
["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||
["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Zugangstyp suchen, z. B. API oder Web", "Search access type, e.g. API or web"], ["Dienst filtern", "Filter service"], ["Alle Dienste", "All services"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
|
||||
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
|
||||
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
getAccountTableMinWidth,
|
||||
getSettingsSelectNavigationIndex,
|
||||
resizeAccountTableColumn,
|
||||
type AccountAddFilter,
|
||||
type AccountAddOption,
|
||||
type AccountRowViewModel,
|
||||
type AccountTableColumnId,
|
||||
@@ -179,7 +178,8 @@ export interface AccountDialogField {
|
||||
export interface AccountAddDialogModel {
|
||||
open: boolean;
|
||||
query: string;
|
||||
filter: AccountAddFilter;
|
||||
serviceFilter: string;
|
||||
serviceFilters: readonly string[];
|
||||
options: readonly AccountAddOption[];
|
||||
selectedOptionId: string | null;
|
||||
fields: readonly AccountDialogField[];
|
||||
@@ -189,7 +189,7 @@ export interface AccountAddDialogModel {
|
||||
|
||||
export interface AccountAddDialogActions {
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterChange: (filter: AccountAddFilter) => void;
|
||||
onServiceFilterChange: (service: string) => void;
|
||||
onOptionSelect: (optionId: string) => void;
|
||||
onFieldChange: (fieldId: string, value: string) => void;
|
||||
onClose: () => void;
|
||||
@@ -667,16 +667,34 @@ export function AccountAddDialog({
|
||||
>
|
||||
<div className="settings-account-picker-selector">
|
||||
<span>Dienst / Zugangstyp</span>
|
||||
<div className="settings-account-picker-controls">
|
||||
<input
|
||||
aria-controls={model.options.length === 0 ? "settings-account-picker-empty" : "settings-account-picker-results"}
|
||||
aria-describedby={model.options.length === 0 ? "settings-account-picker-empty" : undefined}
|
||||
aria-label="Dienst oder Zugangstyp suchen"
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Dienst oder Zugangstyp suchen"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || !model.options[0]) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
actions.onOptionSelect(model.options[0].id);
|
||||
}}
|
||||
placeholder="Zugangstyp suchen, z. B. API oder Web"
|
||||
type="search"
|
||||
value={model.query}
|
||||
/>
|
||||
<select
|
||||
aria-label="Dienst filtern"
|
||||
className="settings-control settings-account-picker-service-filter"
|
||||
onChange={(event) => actions.onServiceFilterChange(event.target.value)}
|
||||
value={model.serviceFilter}
|
||||
>
|
||||
<option value="all">Alle Dienste</option>
|
||||
{model.serviceFilters.map((service) => <option key={service} value={service}>{service}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-account-picker-table">
|
||||
<div aria-hidden="true" className="settings-account-picker-header">
|
||||
|
||||
@@ -936,6 +936,16 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-account-picker-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 190px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-account-picker-service-filter {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-account-picker-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
@@ -968,13 +978,14 @@
|
||||
}
|
||||
|
||||
.settings-account-picker-list {
|
||||
max-height: 190px;
|
||||
height: 190px;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.settings-account-picker-empty {
|
||||
display: grid;
|
||||
min-height: 72px;
|
||||
height: 190px;
|
||||
padding: 16px;
|
||||
place-items: center;
|
||||
color: var(--ui-text-muted);
|
||||
@@ -1159,6 +1170,7 @@
|
||||
}
|
||||
|
||||
.settings-theme-options,
|
||||
.settings-account-picker-controls,
|
||||
.settings-account-picker-header,
|
||||
.settings-account-picker-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildConfiguredProviderOrder,
|
||||
filterAccountDialogOptions,
|
||||
getAccountDialogSelectableOptions,
|
||||
isAccountRowSelectionKey,
|
||||
matchesAccountModeFilter,
|
||||
@@ -22,6 +23,19 @@ describe("account mode filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("account dialog filter", () => {
|
||||
const options = [
|
||||
{ id: "rd-api", serviceLabel: "Real-Debrid", title: "Real-Debrid API", modeLabel: "API", pickerDescription: "API-Token" },
|
||||
{ id: "rd-web", serviceLabel: "Real-Debrid", title: "Real-Debrid Web-Login", modeLabel: "Web-Login", pickerDescription: "Browserfenster" },
|
||||
{ id: "md-api", serviceLabel: "Mega-Debrid", title: "Mega-Debrid API", modeLabel: "API", pickerDescription: "Login:Passwort" }
|
||||
];
|
||||
|
||||
it("combines an exact service choice with an access-type search", () => {
|
||||
expect(filterAccountDialogOptions(options, "web", "Real-Debrid").map((option) => option.id)).toEqual(["rd-web"]);
|
||||
expect(filterAccountDialogOptions(options, "api", "all").map((option) => option.id)).toEqual(["rd-api", "md-api"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account provider order", () => {
|
||||
it("preserves the custom provider order while accounts are disabled", () => {
|
||||
expect(buildConfiguredProviderOrder(
|
||||
|
||||
@@ -885,7 +885,7 @@ describe("account workspace", () => {
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
onQueryChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onServiceFilterChange: () => {},
|
||||
onOptionSelect: () => {},
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
@@ -894,7 +894,8 @@ describe("account workspace", () => {
|
||||
model={{
|
||||
open: true,
|
||||
query: "",
|
||||
filter: "all",
|
||||
serviceFilter: "all",
|
||||
serviceFilters: ["Real-Debrid", "DDownload", "Mega-Debrid", "Debrid-Link"],
|
||||
options,
|
||||
selectedOptionId: "megadebrid-api",
|
||||
fields: [
|
||||
@@ -938,7 +939,9 @@ describe("account workspace", () => {
|
||||
|
||||
expect(addHtml).toContain("Account hinzufügen");
|
||||
expect(addHtml).toContain("Prüfen und speichern");
|
||||
expect(count(addHtml, "<select")).toBe(0);
|
||||
expect(count(addHtml, "<select")).toBe(1);
|
||||
expect(addHtml).toContain('aria-label="Dienst filtern"');
|
||||
expect(addHtml).toContain("Alle Dienste");
|
||||
expect(addHtml).toContain('aria-label="Dienst oder Zugangstyp suchen"');
|
||||
expect(addHtml).toContain('role="listbox"');
|
||||
expect(addHtml).toContain('class="settings-account-picker-header"');
|
||||
@@ -970,7 +973,7 @@ describe("account workspace", () => {
|
||||
const tree = AccountAddDialog({
|
||||
actions: {
|
||||
onQueryChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onServiceFilterChange: () => {},
|
||||
onOptionSelect: (optionId) => selected.push(optionId),
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
@@ -979,7 +982,8 @@ describe("account workspace", () => {
|
||||
model: {
|
||||
open: true,
|
||||
query: "",
|
||||
filter: "all",
|
||||
serviceFilter: "all",
|
||||
serviceFilters: ["Real-Debrid", "DDownload", "Mega-Debrid", "Debrid-Link"],
|
||||
options: accountOptions(),
|
||||
selectedOptionId: "megadebrid-api",
|
||||
fields: [],
|
||||
@@ -994,12 +998,47 @@ describe("account workspace", () => {
|
||||
expect(selected).toEqual(["debridlink-api"]);
|
||||
});
|
||||
|
||||
it("filters by service and selects the first visible option with Enter", () => {
|
||||
const selected: string[] = [];
|
||||
const serviceFilters: string[] = [];
|
||||
const options = accountOptions().filter((option) => option.title === "Mega-Debrid");
|
||||
const tree = AccountAddDialog({
|
||||
actions: {
|
||||
onQueryChange: () => {},
|
||||
onServiceFilterChange: (service) => serviceFilters.push(service),
|
||||
onOptionSelect: (optionId) => selected.push(optionId),
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
onSubmit: () => {}
|
||||
},
|
||||
model: {
|
||||
open: true,
|
||||
query: "api",
|
||||
serviceFilter: "Mega-Debrid",
|
||||
serviceFilters: ["Real-Debrid", "Mega-Debrid"],
|
||||
options,
|
||||
selectedOptionId: options[0].id,
|
||||
fields: [],
|
||||
error: "",
|
||||
busy: false
|
||||
}
|
||||
});
|
||||
const serviceSelect = findElement(tree, (element) => element.props["aria-label"] === "Dienst filtern");
|
||||
const search = findElement(tree, (element) => element.props["aria-label"] === "Dienst oder Zugangstyp suchen");
|
||||
|
||||
serviceSelect.props.onChange({ target: { value: "Real-Debrid" } });
|
||||
search.props.onKeyDown({ key: "Enter", preventDefault: () => selected.push("prevented") });
|
||||
|
||||
expect(serviceFilters).toEqual(["Real-Debrid"]);
|
||||
expect(selected).toEqual(["prevented", "megadebrid-api"]);
|
||||
});
|
||||
|
||||
it("shows an accessible empty result when account search has no matches", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountAddDialog
|
||||
actions={{
|
||||
onQueryChange: () => {},
|
||||
onFilterChange: () => {},
|
||||
onServiceFilterChange: () => {},
|
||||
onOptionSelect: () => {},
|
||||
onFieldChange: () => {},
|
||||
onClose: () => {},
|
||||
@@ -1008,7 +1047,8 @@ describe("account workspace", () => {
|
||||
model={{
|
||||
open: true,
|
||||
query: "nicht vorhanden",
|
||||
filter: "all",
|
||||
serviceFilter: "all",
|
||||
serviceFilters: ["Real-Debrid"],
|
||||
options: [],
|
||||
selectedOptionId: null,
|
||||
fields: [],
|
||||
@@ -1149,6 +1189,8 @@ describe("settings geometry", () => {
|
||||
|
||||
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
|
||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||
expect(css).toMatch(/\.settings-account-picker-list\s*{[^}]*height:\s*190px;[^}]*scrollbar-gutter:\s*stable;/s);
|
||||
expect(css).toMatch(/\.settings-account-picker-empty\s*{[^}]*height:\s*190px;/s);
|
||||
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
|
||||
expect(css).toMatch(
|
||||
/\.md-runtime-view-content\s*>\s*\.settings-content\s*{[^}]*height:\s*100%;[^}]*padding:\s*24px;/s
|
||||
|
||||
Reference in New Issue
Block a user