Skip to content

Commit 065bba7

Browse files
feat(console): marketplace facet filters and a Beta badge from the spec taxonomy (#31187)
domain:ui#1 · os-project-manager · session_01FEkQ5oQ9zXD1QMdHFaqv1J <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XDp4MDE5jLtwYunGcCVPt5v)_ Fixes #31011 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches the `marketplace.category` keys or the console's marketplace filters (`git grep -i "marketplace\.category"` over `skills/` and `content/docs/`: 0 hits); `content/docs/deployment/marketplace-handbook.mdx` says the console's facet chips and Beta badge arrive as each side adopts the taxonomy and a declared value is not yet shown, which stays true until the catalog serves the columns. ## 维护者速读 - 改了什么:控制台市场页的单排「分类」小标签换成三个筛选维度:类型(应用 / 模板 / 连接器,单选,带「全部」)、职能、行业(可多选)。同一维度内是「或」,维度之间是「且」,每个取值后面显示数量。卡片显示主职能和 Beta 徽标,详情页显示全部职能、行业、类型和 Beta。 - 为什么改:旧分类已在 spec 里退役(#30904 的分类方案裁决),控制台还在读 `category`,翻译表也是旧枚举的手抄副本。现在取值直接来自 `@objectstack/spec/marketplace` 的枚举,翻译键按枚举值生成,有测试保证每个值都有翻译,不再另存一份列表。 - 破坏性:`@object-ui/i18n` 删除 `marketplace.category.*` 这组翻译键(旧分类枚举的副本),所以按 Clause-② 声明为 narrowing,changeset 里有 BREAKING 段(FROM / TO / 修法)和 ADR-0087 的 `not-required (runtime-interface-only …)` 处置标记。版本级别仍是 patch。 - 风险与回滚:cloud 目录在 objectstack-ai/cloud#3661 上线前不带这些字段,这期间所有筛选都隐藏,页面就是不带筛选栏的卡片列表(旧的分类小标签也一并去掉)。筛选仍在客户端,只覆盖已加载的 100 条。回滚即 revert。 - 先看:`ui/packages/app-shell/src/console/marketplace/marketplaceTaxonomy.ts`(筛选语义与「无数据即隐藏」),`MarketplaceFacetFilters.tsx`,`ui/packages/i18n/src/__tests__/marketplace-taxonomy-labels-31011.test.ts`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.facets-31011.test.tsx`,预期 `Tests 11 passed (11)`。 - 需要你做的:无,席位落地。 ## What changes - **Facet filters** (`MarketplacePage.tsx`, new `MarketplaceFacetFilters.tsx`, new `marketplaceTaxonomy.ts`). The category chip row is replaced by three facets read from `@objectstack/spec/marketplace`: `solutionType` as a segment with All, `businessFunctions` and `industries` as toggle rows. OR inside a facet, AND across facets and with the search box. Each value shows the count of loaded listings carrying it under the other facets' selection. The values are the spec schemas' `.options`, in the spec's order; a value is drawn when a loaded listing carries it (or it is selected), so the buttons do not jump while filtering. A facet that no loaded listing carries is not drawn: with no taxonomy on the wire there is no filter row at all, and industries hide while no listing carries one. A listing that declares no solution type is counted and filtered as `app` (`DEFAULT_SOLUTION_TYPE`, the spec's reading of an absent key). A taxonomy column that is not an array reads as no values: its facet hides and the page still renders. - **Badges.** A catalog card shows the primary business function (the first the listing names) and a Beta badge when `maturity === 'beta'`. The Details page (`MarketplacePackagePage.tsx`) shows every business function in the listing's order, the industries, the solution type (the declared one, or `app` when none is declared, as the catalog counts it), and the Beta badge, each facet as a labelled group. - **Raw-value fallback dropped.** The old `categoryLabel` fell back to the category's raw spelling when no label existed. A facet value now has to be a spec value to be drawn anywhere; a row carrying anything else draws nothing for it. - **Wire** (`marketplaceApi.ts`). `MarketplacePackageSummary` declares `business_functions`, `industries`, `solution_type` and `maturity`, typed by the spec's taxonomy types, and drops `category`. `listMarketplacePackages` drops its `category` parameter, which had no caller. - **i18n** (`ui/packages/i18n/src/namespaces/marketplace/*.ts`, 10 hand-written catalogues plus `zh-Hant` regenerated by `gen:zh-hant`). `marketplace.category.*` (the copy of the retired enum) is deleted. Added: `marketplace.businessFunction.*` (11), `marketplace.industry.*` (10), `marketplace.solutionType.*` (3), `marketplace.maturity.beta`, and three facet headings `marketplace.facet.*`. - **Call-site key gate** (`ui/scripts/check-i18n-call-site-keys.mjs`). The `marketplace.category.` dynamic family is gone, so its `DYNAMIC_KEY_FAMILIES` entry would read stale; three new heads would read undeclared. The entry is replaced by `marketplace.businessFunction.`, `marketplace.industry.` and `marketplace.solutionType.`, each `enumerable: false` / `why: 'external-vocabulary'` (the vocabulary is a spec enum this reader cannot open), and each reason names the parity test that pins the set. The gate itself is unchanged. One comment in `dead-pack-keys-retired-8754.test.ts` that named the retired head now names a live one. - **Changeset** `.changeset/31011-marketplace-taxonomy-facets.md`: `@object-ui/i18n` (published, its catalogues ship in `dist`) and `@object-ui/app-shell` patch, `Clause-②: yes (narrowing)`, `Metadata-protocol: none`. Removing `marketplace.category.*` from a published package's catalogues narrows what a consumer can read, so the changeset carries a **BREAKING** paragraph (FROM the 15 removed keys; TO no one-for-one replacement, the facet keys `marketplace.businessFunction|industry|solutionType.*` and `LEGACY_CATEGORY_MAPPING` in `@objectstack/spec/marketplace`; the one-line fix) and exactly one ADR-0087 disposition, `not-required (runtime-interface-only ui/packages/i18n/src/namespaces/marketplace/index.ts#MarketplaceCatalogue)`, whose spelling `scripts/check-adr-0087-registration.mjs` accepts ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", exit 0). ## Why The maintainer's taxonomy ruling on #30904 (「关掉,Beta 并入分类方案」 and D1–D3) retired the single `category` for three facets and a maturity flag, and PR #30980 landed them in the spec. The console still built its chips from `category`, and its labels copied the retired enum, so a value cloud served outside that copy was shown as its raw spelling. ## Tests - New `MarketplacePage.facets-31011.test.tsx` (11 cases). Every click goes through the rendered buttons (`fireEvent`), `fetch` is stubbed by path with the catalog body, and labels are the `en` pack's: spec-order facets with counts; OR inside a facet; AND across facets with counts against the other facets; the solution-type segment including the default `app` and All; search plus facets; no taxonomy draws no filter and no badge; industries and type hide while only business functions are served; off-spec values (`crm`, `general`, `theme`) drawn nowhere; the card's primary-function badge; Beta on `beta` only; a list column that is not an array (`business_functions: 'finance'`, `industries: {…}`) reads as no values and the catalog still renders. - New `MarketplacePackagePage.taxonomy-31011.test.tsx` (4 cases): every business function in order, industries, type and Beta; a stable listing with no industries; a listing with no taxonomy draws the default solution type `app` and nothing else; off-spec values and list columns that are not arrays are drawn nowhere. - New `ui/packages/i18n/src/__tests__/marketplace-taxonomy-labels-31011.test.ts` (57 cases): for each of the 11 marketplace catalogues and each of `MarketplaceBusinessFunctionSchema` / `MarketplaceIndustrySchema` / `MarketplaceSolutionTypeSchema`, every `.options` value has a non-empty label and no other key is labelled; `maturity` labels `beta` alone; no catalogue keeps `category`. The catalogue set is held equal to `MARKETPLACE_LANGUAGE_CODES`. - **Ablations** (`scripts/ablation-replace.mjs`, each anchor hit once, each restore proven blob == HEAD and `git diff HEAD` empty; the tests import the edited files as source, so no build sits on the path). See Verification. ## Acceptance notes - **Expected 5 is not in this PR** (seat ruling): server-side filtering and the dead `?category=` filter in `packages/cloud-connection/src/marketplace-proxy-plugin.ts` go to a sibling card the seat files. Filtering here stays on the client over the `limit: 100` page. - **Wire shape is an assumption until objectstack-ai/cloud#3661 ships.** The reader expects `business_functions` and `industries` as JSON arrays of spec values (the spec's shape for the camelCase keys) and `solution_type` / `maturity` as strings. Until the catalog serves them every facet hides, as the card says. - **Off-spec values.** The cloud catalog stores the facets as select fields whose options are the spec's, so an off-spec value should not reach the console; if one does it is drawn nowhere rather than as its raw spelling. Not a tolerance shim: nothing is mapped or aliased. - **Detail page solution type.** An undeclared type is drawn as `app` (`DEFAULT_SOLUTION_TYPE`), the same reading the catalog's filter and counts use, so the two pages agree. - **Filter state is component state**, as the category filter and the search box were; nothing moved to the URL. - **Test fixtures still carrying `category`** in eight older marketplace tests (most of them org-catalog rows, whose `OrgPackageSummary.category` is untouched) were left as they are: they are untyped literals that nothing reads. - **Locale count.** The order named nine namespace locales; the tree has eleven marketplace catalogues (ten hand-written plus the generated `zh-Hant`), all updated. - **`zh` Beta label.** `untranslated-identity-4376.test.ts` refuses an English value verbatim in a non-Latin pack, so `zh-Hans` reads `测试版` (`zh-Hant` `測試版`); the other non-Latin packs translate it too. ## Verification All at head `2296ac23` (base `8ef4d309`; it merges cleanly onto `origin/main` `66bf5b4a`). Heavy runs went through `scripts/pm/os-verify-lock.sh`. Round 1 ran on `4f2029f9`; every run below was repeated on `2296ac23`. <details><summary>Package suites and type-check</summary> - Prerequisite: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter '@object-ui/i18n^...' build`, VERDICT command-exit 0. - `pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/ packages/i18n/`: Test Files 156 passed (156), Tests 2313 passed | 13 skipped (2326). The first run on `b3457e91` had one red, `untranslated-identity-4376.test.ts` (`zh-Hans` / `zh-Hant` `marketplace.maturity.beta` was the English `Beta`); `4f2029f9` translates it. - `pnpm --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` (each runs `tsc --noEmit` and `tsc -p tsconfig.test.json`, which includes the new tests): both Done, VERDICT command-exit 0. - Round 1 also ran `pnpm --dir ui exec eslint` over the touched source and test files: exit 0, 0 errors (55 warnings, all `no-explicit-any` in the pre-existing page and API files). </details> <details><summary>Dogfood battery</summary> - `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` under the lock: Test Files 435 passed | 3 skipped (438), Tests 3609 passed | 41 skipped (3650), VERDICT command-exit 0. No failures. </details> <details><summary>Ablations (10, each red, each restored)</summary> Each through `scripts/ablation-replace.mjs`; the anchor hit once, the mutation was proven on disk by anchor count and blob change, and the restore by blob == HEAD and `git diff HEAD` empty. The tests read the edited files as source, so no build sits on the path. A1 to A8 ran on `4f2029f9`; A9 and A10 cover this round's changes and ran on `2296ac23`. | # | Mutation | Red | |:--|:--|:--| | A1 | OR inside business function turned into every-of (`.some` to `.every`) | facets test 2 failed / 8 passed (OR, AND) | | A2 | the industry selection ignored | 1 failed / 9 passed (AND) | | A3 | Beta for any maturity but `stable` | 3 failed / 10 passed across both page files (Beta only for beta, no-taxonomy cases) | | A4 | every spec value drawn whether or not a listing carries it | 4 failed / 6 passed (spec-order facets, both hide cases, off-spec) | | A5 | the off-spec filter on business functions removed | 2 failed / 11 passed (off-spec drawn nowhere, page and Details) | | A6 | an undeclared solution type not read as `app` | 2 failed / 8 passed (counts, segment) | | A7 | `en` `marketplace.businessFunction.legal` deleted (a spec value with no label) | parity test 1 failed / 56 passed | | A8 | `en` labels a value the spec does not list (`crm`) | parity test 1 failed / 56 passed | | A9 | the `Array.isArray` guard in `readListingTaxonomy` removed (a column trusted as a list) | 2 failed / 13 passed across both page files (the non-array cases on the catalog and on Details) | | A10 | Details draws only a declared solution type | detail test 2 failed / 2 passed (no taxonomy draws `app`; off-spec and non-array) | A8's first attempt was refused by the tool before running (its replacement contained the anchor); it was rerun with a distinct replacement, and the reading above is the rerun. </details> <details><summary>Derived gates (dispatch-gates --commands, 38 families)</summary> - 38 run, all exit 0. `dispatch-gates.mjs --ran`: "38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED". - Two of them did not measure on the first pass and were rerun under the lock. `pnpm --dir ui exec vitest run scripts/` was killed with SIGKILL when memory ran out beside the dogfood build, and `pnpm check:type-check-debt` exited 3 (the lock queue timed out, so nothing was built). On the rerun, `vitest run scripts/` (which holds the call-site key gate's own suite) gave Test Files 170 passed | 2 skipped (172), Tests 4996 passed | 2 skipped (4998). `check:type-check-debt` re-measured 0 ledger entries, with 0 raw tsc errors. - Run beyond the derivation, all exit 0: `pnpm --dir ui run check:i18n-keys`, `check:i18n-dead-keys`, `check:new-line-citations`. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. <!-- signed: domain:ui#1 · os-project-manager · session_01FEkQ5oQ9zXD1QMdHFaqv1J --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86e9ef0 commit 065bba7

22 files changed

Lines changed: 1401 additions & 233 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console)!: the marketplace filters by business function, industry and solution type, and badges a beta package
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/namespaces/marketplace/index.ts#MarketplaceCatalogue) the console's own translation catalogues, served as this type by the marketplace namespace loader and as `en`/`zh`/… of `@object-ui/i18n/locales`: no Zod schema, no spec declaration and no stored metadata reads a translation key, so `objectstack migrate meta` has nothing to rewrite and the compiler (for typed catalogue access) or the rendered key (for a `t()` string) reaches every caller -->
12+
13+
- **Facet filters.** The marketplace page's single row of category chips is replaced by three facet filters, read from the marketplace taxonomy in `@objectstack/spec/marketplace`: solution type (a segment with All), business function and industry (toggle rows, any number of values). Values inside one facet combine with OR, the facets combine with AND, and both combine with the search box. Each value shows the count of loaded listings that carry it under the other facets' selection. A listing that declares no solution type counts as `app`, the spec's default. A facet no loaded listing carries is not drawn, so a catalog that does not serve the taxonomy yet shows no filter at all. Filtering stays on the client, over the page the catalog read loads.
14+
- **Badges.** A catalog card shows the listing's primary business function (the first it lists) and a Beta badge when its `maturity` is `beta`. The package Details page shows every business function in the listing's order, the industries, the solution type and the Beta badge. A value outside the spec's vocabulary is drawn nowhere; the old fallback that showed an unknown category's raw value is gone.
15+
- **Strings.** `@object-ui/i18n`'s marketplace catalogues drop the `marketplace.category.*` labels, a copy of the retired category enum, and gain `marketplace.businessFunction.*`, `marketplace.industry.*`, `marketplace.solutionType.*`, `marketplace.maturity.beta` and three facet headings (`marketplace.facet.*`) in every language. The console keys the labels by the spec enums' own values, and a test fails when a spec value has no label, or a catalogue labels a value the spec does not list.
16+
- **Wire.** The console reads the catalog row's `business_functions`, `industries`, `solution_type` and `maturity` columns, and no longer reads `category` or sends `?category=`.
17+
18+
**BREAKING** (`@object-ui/i18n`, the marketplace catalogues served by `@object-ui/i18n/namespaces/marketplace` and held by `en`, `zh`, `zhHant`, `ja`, `ko`, `de`, `fr`, `es`, `pt`, `ru`, `ar` of `@object-ui/i18n/locales`): the `marketplace.category` group is removed from every language. It was a copy of the retired category enum, whose values the spec no longer declares.
19+
20+
- FROM: `marketplace.category.crm`, `.erp`, `.hr`, `.finance`, `.project`, `.collaboration`, `.analytics`, `.integration`, `.automation`, `.ai`, `.security`, `.developer-tools`, `.ui-theme`, `.storage`, `.other`.
21+
- TO: no one-for-one replacement. A listing is now labelled by its facets: `marketplace.businessFunction.<value>`, `marketplace.industry.<value>` and `marketplace.solutionType.<value>`, keyed by the values of `MarketplaceBusinessFunctionSchema`, `MarketplaceIndustrySchema` and `MarketplaceSolutionTypeSchema` in `@objectstack/spec/marketplace`. Which facets an old category value becomes is `LEGACY_CATEGORY_MAPPING` in the same module.
22+
- Fix: delete a `t('marketplace.category.…')` call or an `en.marketplace.category` read, or label the listing's `businessFunctions` / `industries` / `solutionType` with the facet keys instead. A `t()` call that still names a removed key compiles (its key is a `string`) and renders the key itself.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* The marketplace catalog's facet filters: solution type as a segment (one
3+
* value or All), business function and industry as toggle rows (any number of
4+
* values, OR inside the row). The rows combine with AND. Each value carries
5+
* the count of loaded listings that hold it under the other facets' selection.
6+
*
7+
* `facetOptions` (`marketplaceTaxonomy.ts`) decides what is drawn: a facet no
8+
* loaded listing carries is empty and its row is not drawn, so a catalog that
9+
* does not serve the taxonomy yet draws no filter at all.
10+
*/
11+
12+
import type { ReactNode } from 'react';
13+
import { Button } from '@object-ui/components';
14+
import { useObjectTranslation } from '@object-ui/i18n';
15+
import type { FacetOptions, FacetSelection } from './marketplaceTaxonomy.js';
16+
17+
function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
18+
const next = new Set(set);
19+
if (next.has(value)) next.delete(value);
20+
else next.add(value);
21+
return next;
22+
}
23+
24+
function FacetRow({ label, testId, children }: { label: string; testId: string; children: ReactNode }) {
25+
return (
26+
<div role="group" aria-label={label} data-testid={testId} className="flex flex-wrap items-center gap-1.5">
27+
<span className="mr-1 text-xs font-medium text-muted-foreground" aria-hidden="true">{label}</span>
28+
{children}
29+
</div>
30+
);
31+
}
32+
33+
function FacetButton({ label, count, pressed, onClick, testId }: {
34+
label: string;
35+
count?: number;
36+
pressed: boolean;
37+
onClick: () => void;
38+
testId: string;
39+
}) {
40+
return (
41+
<Button
42+
size="sm"
43+
variant={pressed ? 'default' : 'outline'}
44+
aria-pressed={pressed}
45+
onClick={onClick}
46+
data-testid={testId}
47+
>
48+
{label}
49+
{count !== undefined && (
50+
<span className="ml-1.5 text-xs tabular-nums opacity-70" data-testid={`${testId}-count`}>{count}</span>
51+
)}
52+
</Button>
53+
);
54+
}
55+
56+
export function MarketplaceFacetFilters({ options, selection, onChange }: {
57+
options: FacetOptions;
58+
selection: FacetSelection;
59+
onChange: (next: FacetSelection) => void;
60+
}) {
61+
const { t } = useObjectTranslation();
62+
if (options.solutionType.length === 0 && options.businessFunctions.length === 0 && options.industries.length === 0) {
63+
return null;
64+
}
65+
return (
66+
<div className="flex flex-col gap-2" data-testid="marketplace-facets">
67+
{options.solutionType.length > 0 && (
68+
<FacetRow label={t('marketplace.facet.solutionType')} testId="marketplace-facet-solutionType">
69+
<FacetButton
70+
label={t('marketplace.all')}
71+
pressed={selection.solutionType === null}
72+
onClick={() => onChange({ ...selection, solutionType: null })}
73+
testId="marketplace-facet-solutionType-all"
74+
/>
75+
{options.solutionType.map(({ value, count }) => (
76+
<FacetButton
77+
key={value}
78+
label={t(`marketplace.solutionType.${value}`)}
79+
count={count}
80+
pressed={selection.solutionType === value}
81+
onClick={() => onChange({ ...selection, solutionType: value })}
82+
testId={`marketplace-facet-solutionType-${value}`}
83+
/>
84+
))}
85+
</FacetRow>
86+
)}
87+
{options.businessFunctions.length > 0 && (
88+
<FacetRow label={t('marketplace.facet.businessFunction')} testId="marketplace-facet-businessFunction">
89+
{options.businessFunctions.map(({ value, count }) => (
90+
<FacetButton
91+
key={value}
92+
label={t(`marketplace.businessFunction.${value}`)}
93+
count={count}
94+
pressed={selection.businessFunctions.has(value)}
95+
onClick={() => onChange({ ...selection, businessFunctions: toggled(selection.businessFunctions, value) })}
96+
testId={`marketplace-facet-businessFunction-${value}`}
97+
/>
98+
))}
99+
</FacetRow>
100+
)}
101+
{options.industries.length > 0 && (
102+
<FacetRow label={t('marketplace.facet.industry')} testId="marketplace-facet-industry">
103+
{options.industries.map(({ value, count }) => (
104+
<FacetButton
105+
key={value}
106+
label={t(`marketplace.industry.${value}`)}
107+
count={count}
108+
pressed={selection.industries.has(value)}
109+
onClick={() => onChange({ ...selection, industries: toggled(selection.industries, value) })}
110+
testId={`marketplace-facet-industry-${value}`}
111+
/>
112+
))}
113+
</FacetRow>
114+
)}
115+
</div>
116+
);
117+
}

‎ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
115115
import type { SuggestedBinding } from '../../services/suggestedBindingsApi.js';
116116
import { errorCodeIs } from '@object-ui/types';
117117
import { MarketplaceNamespaceGate } from './MarketplaceNamespaceGate.js';
118+
import { effectiveSolutionType, readListingTaxonomy } from './marketplaceTaxonomy.js';
118119

119120
/**
120121
* Whether a cloud install lands in the environment this console renders, read
@@ -1461,9 +1462,29 @@ function MarketplacePackagePageBody() {
14611462
? data.versions.find((v) => v.version === dialogUpdate.to && !!v.release_notes?.trim())
14621463
: undefined;
14631464

1464-
const categoryLabel = pkg.category
1465-
? t(`marketplace.category.${pkg.category}` as any, { defaultValue: pkg.category })
1466-
: null;
1465+
// The listing's taxonomy (`marketplaceTaxonomy.ts`): every business
1466+
// function in the listing's order, the industries, the solution type, and
1467+
// Beta. An undeclared solution type reads as the spec's default, as the
1468+
// catalog page filters and counts it; the other facets draw nothing when the
1469+
// listing carries none.
1470+
const taxonomy = readListingTaxonomy(pkg);
1471+
const taxonomyGroups = [
1472+
{
1473+
key: 'solutionType',
1474+
label: t('marketplace.facet.solutionType'),
1475+
values: [t(`marketplace.solutionType.${effectiveSolutionType(taxonomy)}`)],
1476+
},
1477+
taxonomy.businessFunctions.length > 0 && {
1478+
key: 'businessFunction',
1479+
label: t('marketplace.facet.businessFunction'),
1480+
values: taxonomy.businessFunctions.map((v) => t(`marketplace.businessFunction.${v}`)),
1481+
},
1482+
taxonomy.industries.length > 0 && {
1483+
key: 'industry',
1484+
label: t('marketplace.facet.industry'),
1485+
values: taxonomy.industries.map((v) => t(`marketplace.industry.${v}`)),
1486+
},
1487+
].filter((g): g is { key: string; label: string; values: string[] } => !!g);
14671488

14681489
// ADR-0131 D6 — the install modes the manifest permits, as the operator's
14691490
// choice: drawn only where it permits more than one. A package that permits
@@ -1578,7 +1599,9 @@ function MarketplacePackagePageBody() {
15781599
{pkg.publisher && pkg.publisher !== 'private' && (
15791600
<Badge variant={pkg.publisher === 'objectstack' ? 'default' : 'secondary'}>{pkg.publisher}</Badge>
15801601
)}
1581-
{categoryLabel && <Badge variant="outline">{categoryLabel}</Badge>}
1602+
{taxonomy.beta && (
1603+
<Badge variant="secondary" data-testid="marketplace-detail-beta">{t('marketplace.maturity.beta')}</Badge>
1604+
)}
15821605
{pkg.license && <Badge variant="outline" className="font-normal">{pkg.license}</Badge>}
15831606
{localInstalledBadge}
15841607
{!localInstall && cloudInstalledVersion && (
@@ -1594,6 +1617,22 @@ function MarketplacePackagePageBody() {
15941617
</Badge>
15951618
)}
15961619
</div>
1620+
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5" data-testid="marketplace-detail-taxonomy">
1621+
{taxonomyGroups.map((group) => (
1622+
<div
1623+
key={group.key}
1624+
role="group"
1625+
aria-label={group.label}
1626+
className="flex flex-wrap items-center gap-1.5"
1627+
data-testid={`marketplace-detail-${group.key}`}
1628+
>
1629+
<span className="text-xs text-muted-foreground" aria-hidden="true">{group.label}</span>
1630+
{group.values.map((value) => (
1631+
<Badge key={value} variant="outline" className="font-normal">{value}</Badge>
1632+
))}
1633+
</div>
1634+
))}
1635+
</div>
15971636
{localNotLoadedReason}
15981637
{loc.description && (
15991638
<p className="text-sm sm:text-base text-foreground/80 mt-3 max-w-2xl leading-relaxed">{loc.description}</p>

‎ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx‎

Lines changed: 41 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
5757
import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
5858
import { packagesHref } from '../../views/metadata-admin/packageSource.js';
5959
import { MarketplaceNamespaceGate } from './MarketplaceNamespaceGate.js';
60+
import { MarketplaceFacetFilters } from './MarketplaceFacetFilters.js';
61+
import {
62+
EMPTY_FACET_SELECTION,
63+
facetOptions,
64+
matchesFacets,
65+
readListingTaxonomy,
66+
type FacetSelection,
67+
} from './marketplaceTaxonomy.js';
6068

6169
/**
6270
* The card grid's track (objectui#12616): a card is never narrower than 240px,
@@ -360,7 +368,7 @@ function MarketplacePageBody() {
360368
phaseTimers.current = [];
361369
};
362370
const [query, setQuery] = useState('');
363-
const [category, setCategory] = useState<string>('');
371+
const [facets, setFacets] = useState<FacetSelection>(EMPTY_FACET_SELECTION);
364372
const [installed, setInstalled] = useState<LocalInstallEntry[]>([]);
365373
// ADR-0007 step ②: the org's own packages (visibility org/private), shown in
366374
// a "Your organization" section and installable inline (no public listing).
@@ -549,32 +557,24 @@ function MarketplacePageBody() {
549557
}
550558
};
551559

552-
const categories = useMemo(() => {
553-
const s = new Set<string>();
554-
for (const it of items) {
555-
if (it.category) s.add(it.category);
556-
}
557-
return Array.from(s).sort();
558-
}, [items]);
559-
560-
// Translate a category enum value via the i18n bundle; fall back to the
561-
// raw value (lowercased) so unknown publisher categories still render.
562-
const categoryLabel = (cat: string): string => {
563-
const key = `marketplace.category.${cat}`;
564-
const translated = t(key, { defaultValue: cat });
565-
return translated === key ? cat : translated;
566-
};
567-
568-
const filtered = useMemo(() => {
560+
// The search box, then the facets (`marketplaceTaxonomy.ts`): OR inside a
561+
// facet, AND across facets and with the search. One memo over the loaded
562+
// page — the catalog read is `limit: 100`, filtered here on the client.
563+
const { filtered, facetChoices } = useMemo(() => {
569564
const q = query.trim().toLowerCase();
570-
return items.filter((it) => {
571-
if (category && it.category !== category) return false;
572-
if (!q) return true;
573-
const loc = localizePackage(it, language);
574-
const hay = `${loc.displayName} ${it.manifest_id} ${loc.description ?? ''}`.toLowerCase();
575-
return hay.includes(q);
576-
});
577-
}, [items, query, category, language]);
565+
const rows = items.map((it) => ({ it, taxonomy: readListingTaxonomy(it) }));
566+
const searched = q
567+
? rows.filter(({ it }) => {
568+
const loc = localizePackage(it, language);
569+
const hay = `${loc.displayName} ${it.manifest_id} ${loc.description ?? ''}`.toLowerCase();
570+
return hay.includes(q);
571+
})
572+
: rows;
573+
return {
574+
filtered: searched.filter(({ taxonomy }) => matchesFacets(taxonomy, facets)),
575+
facetChoices: facetOptions(rows.map((r) => r.taxonomy), searched.map((r) => r.taxonomy), facets),
576+
};
577+
}, [items, query, facets, language]);
578578

579579
// Ahead of the `!isAdmin` guard below deliberately (objectui#5557): on a
580580
// runtime that mounts no marketplace, "there is no marketplace here" is true
@@ -692,7 +692,7 @@ function MarketplacePageBody() {
692692
/>
693693
<div className="flex flex-col gap-6 p-4 sm:p-6">
694694

695-
<div className="flex flex-col sm:flex-row gap-3">
695+
<div className="flex flex-col gap-3">
696696
<div className="relative flex-1">
697697
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
698698
<Input
@@ -703,27 +703,7 @@ function MarketplacePageBody() {
703703
aria-label={t('marketplace.searchAria')}
704704
/>
705705
</div>
706-
{categories.length > 0 && (
707-
<div className="flex flex-wrap gap-1.5">
708-
<Button
709-
size="sm"
710-
variant={category === '' ? 'default' : 'outline'}
711-
onClick={() => setCategory('')}
712-
>
713-
{t('marketplace.all')}
714-
</Button>
715-
{categories.map((cat) => (
716-
<Button
717-
key={cat}
718-
size="sm"
719-
variant={category === cat ? 'default' : 'outline'}
720-
onClick={() => setCategory(cat)}
721-
>
722-
{categoryLabel(cat)}
723-
</Button>
724-
))}
725-
</div>
726-
)}
706+
<MarketplaceFacetFilters options={facetChoices} selection={facets} onChange={setFacets} />
727707
</div>
728708

729709
{/* objectui#12710 — a catalog read still pending once the skeleton's
@@ -900,7 +880,8 @@ function MarketplacePageBody() {
900880
</div>
901881
) : (
902882
<div className={CARD_GRID}>
903-
{filtered.map((pkg) => {
883+
{filtered.map(({ it: pkg, taxonomy }) => {
884+
const primaryFunction = taxonomy.businessFunctions[0];
904885
const localEntry = installedByManifestId.get(pkg.manifest_id);
905886
const loc = localizePackage(pkg, language);
906887
return (
@@ -962,8 +943,17 @@ function MarketplacePageBody() {
962943
{t('marketplace.versionBadge', { version: pkg.latest_version.version })}
963944
</Badge>
964945
)}
965-
{pkg.category && (
966-
<Badge variant="outline" className="text-xs">{categoryLabel(pkg.category)}</Badge>
946+
{/* The primary business function — the first the
947+
listing names — and Beta for a beta package. */}
948+
{primaryFunction && (
949+
<Badge variant="outline" className="text-xs" data-testid="marketplace-card-function">
950+
{t(`marketplace.businessFunction.${primaryFunction}`)}
951+
</Badge>
952+
)}
953+
{taxonomy.beta && (
954+
<Badge variant="secondary" className="text-xs" data-testid="marketplace-card-beta">
955+
{t('marketplace.maturity.beta')}
956+
</Badge>
967957
)}
968958
{pkg.latest_version?.published_at && (
969959
<span className="text-xs text-muted-foreground ml-auto">

0 commit comments

Comments
 (0)