Skip to content

Commit 730df61

Browse files
feat(web): add category filter to provider browser (oomol-lab#262)
Adds a category dropdown to the provider browser with per-category counts, so users exploring by capability can filter instead of scrolling the flat list. Also renders each provider's short description in its card when one exists. Closes oomol-lab#219. --------- Co-authored-by: l1shen <648952316@qq.com>
1 parent afefd00 commit 730df61

12 files changed

Lines changed: 186 additions & 18 deletions

File tree

web/src/i18n.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,25 @@ describe("resolveInitialLang", () => {
6262
describe("createAppI18n", () => {
6363
it("creates an i18n instance with app translations", () => {
6464
const french = createAppI18n("fr");
65+
const japanese = createAppI18n("ja");
6566
const russian = createAppI18n("ru");
67+
const simplifiedChinese = createAppI18n("zh-CN");
6668
const traditionalChinese = createAppI18n("zh-TW");
6769

6870
expect(french.lang).toBe("fr");
6971
expect(french.t("nav.providers")).toBe("Fournisseurs");
7072
expect(french.t("language.fr")).toBe("Français");
73+
expect(french.t("providers.categories.all")).toBe("Toutes les catégories");
74+
expect(japanese.t("providers.categories.all")).toBe("すべてのカテゴリー");
7175
expect(russian.lang).toBe("ru");
7276
expect(russian.t("nav.providers")).toBe("Провайдеры");
7377
expect(russian.t("language.ru")).toBe("Русский");
78+
expect(russian.t("providers.categories.all")).toBe("Все категории");
79+
expect(simplifiedChinese.t("providers.categories.all")).toBe("所有分类");
7480
expect(traditionalChinese.lang).toBe("zh-TW");
7581
expect(traditionalChinese.t("nav.providers")).toBe("服務提供者");
7682
expect(traditionalChinese.t("language.zh-TW")).toBe("繁體中文");
83+
expect(traditionalChinese.t("providers.categories.all")).toBe("所有分類");
7784
expect(supportedLangs).toEqual(["en", "zh-CN", "zh-TW", "ja", "ru", "fr"]);
7885
});
7986
});

web/src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "Executable",
249249
"catalogOnly": "Catalog only"
250+
},
251+
"categoryFilterLabel": "Category",
252+
"categories": {
253+
"all": "All categories"
250254
}
251255
},
252256
"actions": {

web/src/locales/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "Exécutable",
249249
"catalogOnly": "Catalogue uniquement"
250+
},
251+
"categoryFilterLabel": "Catégorie",
252+
"categories": {
253+
"all": "Toutes les catégories"
250254
}
251255
},
252256
"actions": {

web/src/locales/ja.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "実行可能",
249249
"catalogOnly": "カタログのみ"
250+
},
251+
"categoryFilterLabel": "カテゴリー",
252+
"categories": {
253+
"all": "すべてのカテゴリー"
250254
}
251255
},
252256
"actions": {

web/src/locales/ru.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "Выполняемое",
249249
"catalogOnly": "Только каталог"
250+
},
251+
"categoryFilterLabel": "Категория",
252+
"categories": {
253+
"all": "Все категории"
250254
}
251255
},
252256
"actions": {

web/src/locales/zh-CN.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "可执行",
249249
"catalogOnly": "仅目录"
250+
},
251+
"categoryFilterLabel": "分类",
252+
"categories": {
253+
"all": "所有分类"
250254
}
251255
},
252256
"actions": {

web/src/locales/zh-TW.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@
247247
"execution": {
248248
"executable": "可執行",
249249
"catalogOnly": "僅供目錄查閱"
250+
},
251+
"categoryFilterLabel": "分類",
252+
"categories": {
253+
"all": "所有分類"
250254
}
251255
},
252256
"actions": {

web/src/model.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { AppData, ProviderDefinition, RunLog } from "./model";
22

33
import { describe, expect, it } from "vitest";
4-
import { createOverviewSummary, resolveProviderConnectionStatus, sortProviders } from "./model";
4+
import {
5+
createOverviewSummary,
6+
filterProvidersByCategory,
7+
providerCategoryCounts,
8+
resolveProviderConnectionStatus,
9+
sortProviders,
10+
} from "./model";
511

612
function provider(service: string, displayName: string): ProviderDefinition {
713
return {
@@ -101,6 +107,56 @@ describe("sortProviders", () => {
101107
});
102108
});
103109

110+
describe("filterProvidersByCategory", () => {
111+
function categorized(service: string, categories: string[]): ProviderDefinition {
112+
return { ...provider(service, service), categories };
113+
}
114+
115+
it("returns everything for the all filter", () => {
116+
const providers = [categorized("a", ["Data"]), categorized("b", ["Communication"])];
117+
118+
expect(filterProvidersByCategory(providers, "all")).toEqual(providers);
119+
});
120+
121+
it("keeps only providers in the selected category", () => {
122+
const providers = [
123+
categorized("a", ["Data"]),
124+
categorized("b", ["Communication"]),
125+
categorized("c", ["Data", "AI"]),
126+
];
127+
128+
expect(filterProvidersByCategory(providers, "Data").map((item) => item.service)).toEqual(["a", "c"]);
129+
expect(filterProvidersByCategory(providers, "AI").map((item) => item.service)).toEqual(["c"]);
130+
});
131+
132+
it("returns nothing for an unmatched category", () => {
133+
const providers = [categorized("a", ["Data"])];
134+
135+
expect(filterProvidersByCategory(providers, "Storage")).toEqual([]);
136+
});
137+
});
138+
139+
describe("providerCategoryCounts", () => {
140+
it("counts providers per category, including multi-category providers once per category", () => {
141+
const providers = [
142+
{ ...provider("a", "a"), categories: ["Data", "AI"] },
143+
{ ...provider("b", "b"), categories: ["Data"] },
144+
{ ...provider("c", "c"), categories: ["Communication"] },
145+
];
146+
147+
const counts = providerCategoryCounts(providers);
148+
expect(counts.get("Data")).toBe(2);
149+
expect(counts.get("AI")).toBe(1);
150+
expect(counts.get("Communication")).toBe(1);
151+
expect(counts.get("Storage")).toBeUndefined();
152+
});
153+
154+
it("handles providers without categories", () => {
155+
const counts = providerCategoryCounts([provider("a", "a"), provider("b", "b")]);
156+
expect(counts.size).toBe(0);
157+
});
158+
});
159+
104160
describe("createOverviewSummary", () => {
105161
it("counts locally executable actions", () => {
106162
const clock = {

web/src/model.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,21 @@ export function filterProviders(providers: ProviderDefinition[], query: string):
370370
);
371371
}
372372

373+
export function filterProvidersByCategory(providers: ProviderDefinition[], category: string): ProviderDefinition[] {
374+
if (category === "all") return providers;
375+
return providers.filter((provider) => provider.categories.includes(category));
376+
}
377+
378+
export function providerCategoryCounts(providers: ProviderDefinition[]): Map<string, number> {
379+
const counts = new Map<string, number>();
380+
for (const provider of providers) {
381+
for (const category of provider.categories) {
382+
counts.set(category, (counts.get(category) ?? 0) + 1);
383+
}
384+
}
385+
return counts;
386+
}
387+
373388
export function sortProviders(
374389
providers: ProviderDefinition[],
375390
connectionsByService: Map<string, ConnectionRecord>,

web/src/providers-page.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,14 @@ describe("isProviderLocallyAvailable", () => {
443443
});
444444

445445
describe("providerBrowserResetKey", () => {
446-
it("changes when search or status filters change", () => {
447-
expect(providerBrowserResetKey("gmail", "all")).not.toBe(providerBrowserResetKey("gmail", "connected"));
448-
expect(providerBrowserResetKey("gmail", "all")).not.toBe(providerBrowserResetKey("slack", "all"));
446+
it("changes when search, status, or category filters change", () => {
447+
expect(providerBrowserResetKey("gmail", "all", "all")).not.toBe(
448+
providerBrowserResetKey("gmail", "connected", "all"),
449+
);
450+
expect(providerBrowserResetKey("gmail", "all", "all")).not.toBe(providerBrowserResetKey("slack", "all", "all"));
451+
expect(providerBrowserResetKey("gmail", "all", "all")).not.toBe(
452+
providerBrowserResetKey("gmail", "all", "Communication"),
453+
);
449454
});
450455
});
451456

0 commit comments

Comments
 (0)