diff --git a/.changeset/31267-marketplace-server-facets.md b/.changeset/31267-marketplace-server-facets.md new file mode 100644 index 00000000000..7ed3ba6b67f --- /dev/null +++ b/.changeset/31267-marketplace-server-facets.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +--- + +feat(console): the marketplace catalog is filtered and paged by the server + +Clause-②: no +Metadata-protocol: none + +The Console's marketplace page read the first 100 listings once and filtered them in the browser, so a catalog of more than 100 listings could never show the rest. It now reads one page at a time from `GET /api/v1/marketplace/packages`, and the search text, the facet selection and the page window travel with the read: + +- `businessFunctions`, `industries` and `solutionTypes`, each one comma-joined parameter (`?businessFunctions=sales,marketing`), OR inside a facet and AND across facets, as the list door applies them. A facet with nothing selected is left out, never sent empty, and the retired `category` is never sent. +- The search box sends its text as `q` once typing pauses, and the list door does the matching. +- `limit` / `offset`: a pager under the cards walks the door's `total`, 24 listings a page. A new search or selection starts again from the first page. + +`listMarketplacePackages` takes the four filters, `maturity` included, which the page itself does not select. + +A drawn facet now lists every value the marketplace declares, in the spec's order, and no longer prints a count beside each value: one page of the filtered catalog cannot count the catalog, and the list door answers no counts. A facet appears once some listing the door answered carries it, and stays while a selection narrows the page, so a catalog that serves no taxonomy still draws no filter. diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplaceFacetFilters.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplaceFacetFilters.tsx index ad8f834e6bf..8d4449a471f 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplaceFacetFilters.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplaceFacetFilters.tsx @@ -1,18 +1,26 @@ /** * The marketplace catalog's facet filters: solution type as a segment (one * value or All), business function and industry as toggle rows (any number of - * values, OR inside the row). The rows combine with AND. Each value carries - * the count of loaded listings that hold it under the other facets' selection. + * values, OR inside the row). The rows combine with AND. The server applies + * the selection: the page sends it with the list read and draws the answer. * - * `facetOptions` (`marketplaceTaxonomy.ts`) decides what is drawn: a facet no - * loaded listing carries is empty and its row is not drawn, so a catalog that + * A drawn facet offers every value the spec declares, in the spec's order: the + * page holds one page of the filtered catalog, so the values its rows carry + * are not the values a selection can add. `ServedFacets` + * (`marketplaceTaxonomy.ts`) decides which facets are drawn, so a catalog that * does not serve the taxonomy yet draws no filter at all. */ import type { ReactNode } from 'react'; import { Button } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; -import type { FacetOptions, FacetSelection } from './marketplaceTaxonomy.js'; +import { + BUSINESS_FUNCTIONS, + INDUSTRIES, + SOLUTION_TYPES, + type FacetSelection, + type ServedFacets, +} from './marketplaceTaxonomy.js'; function toggled(set: ReadonlySet, value: T): Set { const next = new Set(set); @@ -30,9 +38,8 @@ function FacetRow({ label, testId, children }: { label: string; testId: string; ); } -function FacetButton({ label, count, pressed, onClick, testId }: { +function FacetButton({ label, pressed, onClick, testId }: { label: string; - count?: number; pressed: boolean; onClick: () => void; testId: string; @@ -46,25 +53,20 @@ function FacetButton({ label, count, pressed, onClick, testId }: { data-testid={testId} > {label} - {count !== undefined && ( - {count} - )} ); } -export function MarketplaceFacetFilters({ options, selection, onChange }: { - options: FacetOptions; +export function MarketplaceFacetFilters({ served, selection, onChange }: { + served: ServedFacets; selection: FacetSelection; onChange: (next: FacetSelection) => void; }) { const { t } = useObjectTranslation(); - if (options.solutionType.length === 0 && options.businessFunctions.length === 0 && options.industries.length === 0) { - return null; - } + if (!served.solutionType && !served.businessFunctions && !served.industries) return null; return (
- {options.solutionType.length > 0 && ( + {served.solutionType && ( onChange({ ...selection, solutionType: null })} testId="marketplace-facet-solutionType-all" /> - {options.solutionType.map(({ value, count }) => ( + {SOLUTION_TYPES.map((value) => ( onChange({ ...selection, solutionType: value })} testId={`marketplace-facet-solutionType-${value}`} @@ -84,13 +85,12 @@ export function MarketplaceFacetFilters({ options, selection, onChange }: { ))} )} - {options.businessFunctions.length > 0 && ( + {served.businessFunctions && ( - {options.businessFunctions.map(({ value, count }) => ( + {BUSINESS_FUNCTIONS.map((value) => ( onChange({ ...selection, businessFunctions: toggled(selection.businessFunctions, value) })} testId={`marketplace-facet-businessFunction-${value}`} @@ -98,13 +98,12 @@ export function MarketplaceFacetFilters({ options, selection, onChange }: { ))} )} - {options.industries.length > 0 && ( + {served.industries && ( - {options.industries.map(({ value, count }) => ( + {INDUSTRIES.map((value) => ( onChange({ ...selection, industries: toggled(selection.industries, value) })} testId={`marketplace-facet-industry-${value}`} diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index 86c6280eec1..fd727514949 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -4,6 +4,10 @@ * Card-grid catalog of public packages, fetched from the tenant runtime's * `/api/v1/marketplace/packages` proxy (which forwards to the configured * cloud control plane). Click-through opens the package detail page. + * + * The catalog is read one page at a time: the search text and the facet + * selection travel with the read, and the server filters before it pages, so + * the page filters nothing itself. */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; @@ -41,6 +45,7 @@ import { listInstalledPackages, installPackage, installLocal, + type MarketplaceListParams, type MarketplacePackageSummary, type LocalInstallEntry, type OrgPackageSummary, @@ -60,10 +65,12 @@ import { MarketplaceNamespaceGate } from './MarketplaceNamespaceGate.js'; import { MarketplaceFacetFilters } from './MarketplaceFacetFilters.js'; import { EMPTY_FACET_SELECTION, - facetOptions, - matchesFacets, + NO_SERVED_FACETS, + facetListParams, readListingTaxonomy, + widenServedFacets, type FacetSelection, + type ServedFacets, } from './marketplaceTaxonomy.js'; /** @@ -300,6 +307,15 @@ const SLOW_MS = 10_000; /** Where a pending catalog read is in the timeline above. */ type LoadPhase = 'skeleton' | 'line' | 'slow'; +/** + * Listings per catalog page. The list door caps a page at 100; a page is a + * whole number of rows of the card grid at two, three, four and six columns. + */ +export const CATALOG_PAGE_SIZE = 24; + +/** How long the search box waits after the last keystroke before it reads. */ +export const SEARCH_DEBOUNCE_MS = 300; + /** * objectui#12097 — an org install on a cloud-managed environment, from the * install answering until the runtime serves the package's app, or the wait @@ -368,7 +384,20 @@ function MarketplacePageBody() { phaseTimers.current = []; }; const [query, setQuery] = useState(''); + // The search text the catalog read sends: the box's text once typing pauses + // for `SEARCH_DEBOUNCE_MS`, trimmed. + const [searchText, setSearchText] = useState(''); const [facets, setFacets] = useState(EMPTY_FACET_SELECTION); + // The first listing of the page on screen; a new search or selection reads + // from the first page again. + const [offset, setOffset] = useState(0); + // The listings the catalog holds under the current search and selection. + const [total, setTotal] = useState(0); + // The facets some answered listing carried (`ServedFacets`). + const [servedFacets, setServedFacets] = useState(NO_SERVED_FACETS); + // Whether a read has settled the side loads (installs, org packages); a + // read for another page or selection then reads the catalog alone. + const sideLoaded = useRef(false); const [installed, setInstalled] = useState([]); // ADR-0007 step ②: the org's own packages (visibility org/private), shown in // a "Your organization" section and installable inline (no public listing). @@ -395,7 +424,27 @@ function MarketplacePageBody() { return m; }, [installed]); - const load = async () => { + // The catalog read the page is showing: search, selection and page window. + // `catalogRequestKey` is its spelling, and the read effect's dependency. + const facetParams = facetListParams(facets); + const catalogRequest: MarketplaceListParams = { + ...(searchText ? { q: searchText } : {}), + ...facetParams, + limit: CATALOG_PAGE_SIZE, + offset, + }; + const catalogRequestKey = JSON.stringify(catalogRequest); + // Whether the read narrows the catalog: an empty answer then means "nothing + // matches", not "nothing is listed". + const filtering = searchText !== '' || Object.keys(facetParams).length > 0; + + /** + * Read the catalog page `request` names. `withSideLoads` reads the installs + * and the org's packages with it — the first read, Refresh, Retry and the + * read after an install; turning a page or changing the selection reads the + * catalog alone. + */ + const load = async (request: MarketplaceListParams = catalogRequest, withSideLoads = true) => { const seq = ++loadSeq.current; const current = () => loadSeq.current === seq; clearPhaseTimers(); @@ -407,33 +456,41 @@ function MarketplacePageBody() { setLoading(true); setError(null); try { - const [catalog, installs, org, cloudInstalled] = await Promise.all([ + const [catalog, sides] = await Promise.all([ // Settled here rather than left to reject the batch. The three side // loads each answer "nothing" on their own failure, so the catalog is // the only one that can fail, and its failure must not discard what // they loaded: the org section and the installed count (objectui#11688). - listMarketplacePackages({ limit: 100 }).then( + listMarketplacePackages(request).then( (resp) => ({ resp, failure: null }), (e: unknown) => ({ resp: null, failure: toLoadFailure(e) }), ), - listLocalInstalls(), - listOrgPackages(), - listInstalledPackages(), + withSideLoads + ? Promise.all([listLocalInstalls(), listOrgPackages(), listInstalledPackages()]) + : null, ]); if (!current()) return; - setInstalled(installs); - setOrgItems(org.items ?? []); - const ids = new Set(); - const disabled = new Set(); - for (const e of installs) ids.add(e.manifestId); - for (const e of cloudInstalled.items) (e.disabled ? disabled : ids).add(e.manifestId); - setInstalledIds(ids); - setDisabledIds(disabled); + if (sides !== null) { + const [installs, org, cloudInstalled] = sides; + setInstalled(installs); + setOrgItems(org.items ?? []); + const ids = new Set(); + const disabled = new Set(); + for (const e of installs) ids.add(e.manifestId); + for (const e of cloudInstalled.items) (e.disabled ? disabled : ids).add(e.manifestId); + setInstalledIds(ids); + setDisabledIds(disabled); + sideLoaded.current = true; + } if (catalog.failure !== null) { setError(catalog.failure); setItems([]); + setTotal(0); } else { - setItems(catalog.resp.items ?? []); + const page = catalog.resp.items ?? []; + setItems(page); + setTotal(typeof catalog.resp.total === 'number' ? catalog.resp.total : page.length); + setServedFacets((served) => widenServedFacets(served, page.map(readListingTaxonomy))); } } catch (e: unknown) { if (!current()) return; @@ -452,14 +509,32 @@ function MarketplacePageBody() { // a guaranteed 404 in the network log and leave the page spinning // before it settled on the disabled state. if (!marketplaceEnabled) return; - void load(); - // Unmount retires whatever read is pending and its phase timers - // (objectui#12710). + void load(catalogRequest, !sideLoaded.current); + // Unmount, or a new search, selection or page, retires whatever read is + // pending and its phase timers (objectui#12710): only the newest read + // settles the page. return () => { loadSeq.current += 1; clearPhaseTimers(); }; - }, [marketplaceEnabled]); + }, [marketplaceEnabled, catalogRequestKey]); + + // The search box reads once typing pauses, from the first page. + useEffect(() => { + const id = setTimeout(() => { + const next = query.trim(); + if (next === searchText) return; + setSearchText(next); + setOffset(0); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [query]); + + // A new selection reads from the first page. + const selectFacets = (next: FacetSelection) => { + setFacets(next); + setOffset(0); + }; /** * objectui#12097 — wait until this environment serves the installed org @@ -557,24 +632,10 @@ function MarketplacePageBody() { } }; - // The search box, then the facets (`marketplaceTaxonomy.ts`): OR inside a - // facet, AND across facets and with the search. One memo over the loaded - // page — the catalog read is `limit: 100`, filtered here on the client. - const { filtered, facetChoices } = useMemo(() => { - const q = query.trim().toLowerCase(); - const rows = items.map((it) => ({ it, taxonomy: readListingTaxonomy(it) })); - const searched = q - ? rows.filter(({ it }) => { - const loc = localizePackage(it, language); - const hay = `${loc.displayName} ${it.manifest_id} ${loc.description ?? ''}`.toLowerCase(); - return hay.includes(q); - }) - : rows; - return { - filtered: searched.filter(({ taxonomy }) => matchesFacets(taxonomy, facets)), - facetChoices: facetOptions(rows.map((r) => r.taxonomy), searched.map((r) => r.taxonomy), facets), - }; - }, [items, query, facets, language]); + // The page the server answered, already searched, filtered and paged. + const shown = useMemo(() => items.map((it) => ({ it, taxonomy: readListingTaxonomy(it) })), [items]); + const pageCount = Math.max(1, Math.ceil(total / CATALOG_PAGE_SIZE)); + const pageNumber = Math.floor(offset / CATALOG_PAGE_SIZE) + 1; // Ahead of the `!isAdmin` guard below deliberately (objectui#5557): on a // runtime that mounts no marketplace, "there is no marketplace here" is true @@ -703,7 +764,7 @@ function MarketplacePageBody() { aria-label={t('marketplace.searchAria')} />
- + {/* objectui#12710 — a catalog read still pending once the skeleton's @@ -874,13 +935,14 @@ function MarketplacePageBody() { - ) : filtered.length === 0 ? ( -
- {items.length === 0 ? t('marketplace.noApprovedYet') : t('marketplace.noMatchFilters')} + ) : shown.length === 0 ? ( +
+ {filtering ? t('marketplace.noMatchFilters') : t('marketplace.noApprovedYet')}
) : ( + <>
- {filtered.map(({ it: pkg, taxonomy }) => { + {shown.map(({ it: pkg, taxonomy }) => { const primaryFunction = taxonomy.businessFunctions[0]; const localEntry = installedByManifestId.get(pkg.manifest_id); const loc = localizePackage(pkg, language); @@ -966,6 +1028,36 @@ function MarketplacePageBody() { ); })}
+ {pageCount > 1 && ( + + )} + )}
diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.facets-31011.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.facets-31011.test.tsx index ee25df81b74..9e4a9326386 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.facets-31011.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.facets-31011.test.tsx @@ -2,35 +2,45 @@ /** * The catalog's facet filters and card badges read the marketplace taxonomy - * of `@objectstack/spec/marketplace` (objectstack#31011). + * of `@objectstack/spec/marketplace` (objectstack#31011), and the catalog list + * door filters and pages by it (objectstack#31267). * * ## What the cases pin * - * - Business function and industry are toggle rows: OR inside a row. Solution - * type is a segment with All. The rows combine with AND, and with the search - * box. Each value carries the count of listings that hold it under the other - * facets' selection. - * - A facet no loaded listing carries is not drawn: with no taxonomy on the - * wire there is no filter at all, and industries hide while only business - * functions are served. - * - A listing that declares no solution type is counted and filtered under - * the spec's default, `app`. + * - The page filters nothing itself. The search text, the selection and the + * page window travel with the list read, spelled as the door reads them: + * `businessFunctions`, `industries` and `solutionTypes`, each ONE + * comma-joined parameter in the spec's order, a facet with nothing selected + * left out (never sent empty), `limit` / `offset`, and never `category`. The + * cards drawn are the server's answer, whatever they carry. + * - Business function and industry are toggle rows (several values, one + * parameter); solution type is a segment with All, which drops the + * parameter. A new search or selection reads from the first page. + * - The pager walks the server's `total` by `offset`. + * - A drawn facet offers every spec value, in the spec's order. A facet no + * answered listing carries is not drawn: with no taxonomy on the wire there + * is no filter at all. Once drawn, a facet stays drawn while a selection + * narrows the page to listings without it. * - The card shows the primary business function (the first listed) and a - * Beta badge for `maturity: 'beta'` only. - * - A value outside the spec's vocabulary is drawn nowhere, neither as a - * facet value nor as a badge: no raw spelling reaches the page. - * - A list column that is not an array (the proxy hands rows over unparsed) - * reads as no values: its facet hides and the catalog still renders. + * Beta badge for `maturity: 'beta'` only. A value outside the spec's + * vocabulary is drawn nowhere; a list column that is not an array reads as + * no values and the catalog still renders. * - * Every click goes through the rendered buttons. `fetch` is stubbed by path - * with the catalog body the proxy serves; the labels are the `en` pack's. + * Every click and keystroke goes through the rendered page. `fetch` is + * stubbed by path; the catalog stub records each list read's query and answers + * from `catalogAnswer`. The labels are the `en` pack's. */ import '@testing-library/jest-dom/vitest'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { I18nProvider } from '@object-ui/i18n'; import { en } from '@object-ui/i18n/locales'; +import { + MarketplaceBusinessFunctionSchema, + MarketplaceIndustrySchema, + MarketplaceSolutionTypeSchema, +} from '@objectstack/spec/marketplace'; vi.mock('react-router-dom', () => { const navigate = () => {}; @@ -46,7 +56,7 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} })); import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; -import { MarketplacePage } from '../MarketplacePage'; +import { CATALOG_PAGE_SIZE, MarketplacePage } from '../MarketplacePage'; const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; const L = en.marketplace; @@ -85,12 +95,20 @@ const TAXONOMY_CATALOG = [SALES, HIRING, BILLING, UNTYPED]; const answer = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } }); -async function boot(items: unknown[]) { +/** Each catalog list read's query, in order. */ +let catalogQueries: URLSearchParams[] = []; +/** The rows and total the stubbed list door answers a read with. */ +let catalogAnswer: (query: URLSearchParams) => { items: unknown[]; total?: number } = () => ({ items: [] }); + +async function boot(answerWith: typeof catalogAnswer) { resetRuntimeConfigForTesting(); + catalogQueries = []; + catalogAnswer = answerWith; vi.stubGlobal( 'fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { - const path = new URL(String(input), 'http://console.test').pathname; + const url = new URL(String(input), 'http://console.test'); + const path = url.pathname; const method = (init?.method ?? 'GET').toUpperCase(); if (path === '/api/v1/runtime/config') { return answer(200, { @@ -101,7 +119,11 @@ async function boot(items: unknown[]) { }); } if (path === '/api/v1/marketplace/packages' && method === 'GET') { - return answer(200, { success: true, data: { items, total: items.length, limit: 100, offset: 0 } }); + catalogQueries.push(url.searchParams); + const { items, total } = catalogAnswer(url.searchParams); + const limit = Number(url.searchParams.get('limit')); + const offset = Number(url.searchParams.get('offset')); + return answer(200, { success: true, data: { items, total: total ?? items.length, limit, offset } }); } if (path === '/api/v1/marketplace/install-local' && method === 'GET') { return answer(200, { success: true, data: { items: [], total: 0 } }); @@ -112,15 +134,27 @@ async function boot(items: unknown[]) { await initRuntimeConfig(); } -async function renderPage(items: unknown[]) { - await boot(items); +/** Render the page; it has answered once `ready` is on screen. */ +async function renderPage(answerWith: typeof catalogAnswer, ready?: string) { + await boot(answerWith); render( , ); - // The catalog has answered once its first card is drawn. - await screen.findByTestId(`marketplace-card-${(items[0] as { manifest_id: string }).manifest_id}`); + const first = answerWith(new URLSearchParams()).items[0] as { manifest_id: string } | undefined; + await screen.findByTestId(ready ?? `marketplace-card-${first!.manifest_id}`); +} + +/** The whole catalog, every read: the console's filtering is not exercised. */ +const always = (items: unknown[]) => () => ({ items }); + +/** The newest list read's query, as `key=value` pairs in the order sent. */ +const lastQuery = () => [...catalogQueries[catalogQueries.length - 1]!.entries()].map(([k, v]) => `${k}=${v}`); + +/** Wait for a list read whose query is exactly `pairs`, then for its answer to be drawn. */ +async function expectRead(pairs: string[]) { + await waitFor(() => expect(lastQuery()).toEqual(pairs)); } /** The manifest ids of the cards drawn, in order. */ @@ -128,8 +162,10 @@ const shownCards = () => screen.queryAllByTestId(/^marketplace-card-com\./).map((el) => el.getAttribute('data-testid')!.replace('marketplace-card-', '')); const facetButton = (facet: string, value: string) => screen.getByTestId(`marketplace-facet-${facet}-${value}`); -const facetCount = (facet: string, value: string) => - screen.getByTestId(`marketplace-facet-${facet}-${value}-count`).textContent; +const facetValues = (name: string) => + within(screen.getByRole('group', { name })).getAllByRole('button').map((b) => b.getAttribute('data-testid')); + +const WINDOW = [`limit=${CATALOG_PAGE_SIZE}`, 'offset=0']; afterEach(() => { cleanup(); @@ -137,103 +173,156 @@ afterEach(() => { resetRuntimeConfigForTesting(); }); -describe('Marketplace facet filters (objectstack#31011)', () => { - it('draws the three facets in the spec order, labelled from the pack, each value with its count', async () => { - await renderPage(TAXONOMY_CATALOG); - - const fn = screen.getByRole('group', { name: L.facet.businessFunction }); - // Only the values a loaded listing carries, in the spec's order. - expect(within(fn).getAllByRole('button').map((b) => b.getAttribute('data-testid'))).toEqual([ - 'marketplace-facet-businessFunction-sales', - 'marketplace-facet-businessFunction-marketing', - 'marketplace-facet-businessFunction-customer_service', - 'marketplace-facet-businessFunction-finance', - 'marketplace-facet-businessFunction-hr', +describe('Marketplace facet filters read through the list door (objectstack#31011, objectstack#31267)', () => { + it('the first read sends the page window and no filter; every spec value is offered, without a count', async () => { + await renderPage(always(TAXONOMY_CATALOG)); + + expect(catalogQueries).toHaveLength(1); + expect(lastQuery()).toEqual(WINDOW); + + expect(facetValues(L.facet.businessFunction)).toEqual( + MarketplaceBusinessFunctionSchema.options.map((v) => `marketplace-facet-businessFunction-${v}`), + ); + expect(facetValues(L.facet.industry)).toEqual( + MarketplaceIndustrySchema.options.map((v) => `marketplace-facet-industry-${v}`), + ); + expect(facetValues(L.facet.solutionType)).toEqual([ + 'marketplace-facet-solutionType-all', + ...MarketplaceSolutionTypeSchema.options.map((v) => `marketplace-facet-solutionType-${v}`), ]); expect(facetButton('businessFunction', 'sales')).toHaveTextContent(L.businessFunction.sales); - expect(facetCount('businessFunction', 'sales')).toBe('2'); - expect(facetCount('businessFunction', 'hr')).toBe('1'); - - expect(screen.getByRole('group', { name: L.facet.industry })).toBeInTheDocument(); expect(facetButton('industry', 'manufacturing')).toHaveTextContent(L.industry.manufacturing); - - const type = screen.getByRole('group', { name: L.facet.solutionType }); - expect(within(type).getByRole('button', { name: L.all })).toHaveAttribute('aria-pressed', 'true'); - // The untyped listing counts under the default, `app`. - expect(facetCount('solutionType', 'app')).toBe('2'); - expect(facetCount('solutionType', 'template')).toBe('1'); - expect(facetCount('solutionType', 'connector')).toBe('1'); + // No count: a page of the filtered catalog cannot count the catalog. + expect(facetButton('businessFunction', 'sales')).toHaveTextContent(new RegExp(`^${L.businessFunction.sales}$`)); + expect(screen.queryByTestId('marketplace-facet-businessFunction-sales-count')).toBeNull(); + expect(within(screen.getByRole('group', { name: L.facet.solutionType })).getByRole('button', { name: L.all })) + .toHaveAttribute('aria-pressed', 'true'); }); - it('ORs the values inside one facet', async () => { - await renderPage(TAXONOMY_CATALOG); - - fireEvent.click(facetButton('businessFunction', 'sales')); - expect(facetButton('businessFunction', 'sales')).toHaveAttribute('aria-pressed', 'true'); - expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.billing']); + it('a toggle row sends its values as one comma-joined parameter, in the spec order, and drops it when cleared', async () => { + await renderPage(always(TAXONOMY_CATALOG)); fireEvent.click(facetButton('businessFunction', 'hr')); - expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.hiring', 'com.acme.billing']); + expect(facetButton('businessFunction', 'hr')).toHaveAttribute('aria-pressed', 'true'); + await expectRead(['businessFunctions=hr', ...WINDOW]); - // A second click clears the value. fireEvent.click(facetButton('businessFunction', 'sales')); + await expectRead(['businessFunctions=sales,hr', ...WINDOW]); + + // A second click clears a value; the last one cleared drops the parameter. fireEvent.click(facetButton('businessFunction', 'hr')); - expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.hiring', 'com.acme.billing', 'com.acme.untyped']); + await expectRead(['businessFunctions=sales', ...WINDOW]); + fireEvent.click(facetButton('businessFunction', 'sales')); + await expectRead(WINDOW); + + for (const query of catalogQueries) { + expect(query.getAll('businessFunctions').length).toBeLessThanOrEqual(1); + expect(query.has('category')).toBe(false); + for (const [, value] of query) expect(value).not.toBe(''); + } }); - it('ANDs the facets, and counts each against the others', async () => { - await renderPage(TAXONOMY_CATALOG); + it('the facets travel together, and the segment sends one solution type until All drops it', async () => { + await renderPage(always(TAXONOMY_CATALOG)); fireEvent.click(facetButton('businessFunction', 'sales')); + fireEvent.click(facetButton('industry', 'retail')); fireEvent.click(facetButton('industry', 'manufacturing')); - expect(shownCards()).toEqual(['com.acme.sales']); - // Sales under the industry selection: one listing; the industry facet - // counts under the business-function selection. - expect(facetCount('businessFunction', 'sales')).toBe('1'); - expect(facetCount('industry', 'retail')).toBe('1'); - fireEvent.click(facetButton('solutionType', 'connector')); - expect(shownCards()).toEqual([]); - expect(screen.getByText(L.noMatchFilters)).toBeInTheDocument(); + await expectRead([ + 'businessFunctions=sales', + 'industries=manufacturing,retail', + 'solutionTypes=connector', + ...WINDOW, + ]); - fireEvent.click(facetButton('industry', 'retail')); - expect(shownCards()).toEqual(['com.acme.billing']); + fireEvent.click(facetButton('solutionType', 'template')); + expect(facetButton('solutionType', 'connector')).toHaveAttribute('aria-pressed', 'false'); + await expectRead(['businessFunctions=sales', 'industries=manufacturing,retail', 'solutionTypes=template', ...WINDOW]); + + fireEvent.click(facetButton('solutionType', 'all')); + await expectRead(['businessFunctions=sales', 'industries=manufacturing,retail', ...WINDOW]); }); - it('the solution-type segment selects one value, the default included, and All clears it', async () => { - await renderPage(TAXONOMY_CATALOG); + it('draws the cards the server answered, and filters none of them out', async () => { + // The stub answers a `hr` read with listings that carry no `hr` at all: the + // console draws them, because filtering is the server's. + await renderPage((query) => ({ items: query.has('businessFunctions') ? [SALES, BILLING] : TAXONOMY_CATALOG })); - fireEvent.click(facetButton('solutionType', 'template')); - expect(shownCards()).toEqual(['com.acme.hiring']); + expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.hiring', 'com.acme.billing', 'com.acme.untyped']); + fireEvent.click(facetButton('businessFunction', 'hr')); + await waitFor(() => expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.billing'])); + }); - fireEvent.click(facetButton('solutionType', 'app')); - expect(facetButton('solutionType', 'template')).toHaveAttribute('aria-pressed', 'false'); - expect(shownCards()).toEqual(['com.acme.sales', 'com.acme.untyped']); + it('the search box reads once typing pauses, with the selection, from the first page', async () => { + await renderPage((query) => ({ items: TAXONOMY_CATALOG, total: query.has('q') ? 4 : 60 })); - fireEvent.click(facetButton('solutionType', 'all')); - expect(shownCards()).toHaveLength(4); + fireEvent.click(screen.getByTestId('marketplace-pager-next')); + await expectRead([`limit=${CATALOG_PAGE_SIZE}`, `offset=${CATALOG_PAGE_SIZE}`]); + fireEvent.click(facetButton('industry', 'retail')); + await expectRead(['industries=retail', ...WINDOW]); + const reads = catalogQueries.length; + + fireEvent.change(screen.getByLabelText(L.searchAria), { target: { value: ' Billing' } }); + await expectRead(['q=Billing', 'industries=retail', ...WINDOW]); + // The keystrokes made one read, after the pause. + expect(catalogQueries).toHaveLength(reads + 1); }); - it('the search box and the facets combine', async () => { - await renderPage(TAXONOMY_CATALOG); + it('an empty answer says nothing matches under a filter, and that nothing is listed without one', async () => { + await renderPage((query) => ({ items: query.has('industries') ? [] : TAXONOMY_CATALOG })); - fireEvent.change(screen.getByLabelText(L.searchAria), { target: { value: 'connector' } }); - expect(shownCards()).toEqual(['com.acme.billing']); - // The buttons stay; their counts follow the search. - expect(facetCount('businessFunction', 'hr')).toBe('0'); - expect(facetCount('businessFunction', 'sales')).toBe('1'); + fireEvent.click(facetButton('industry', 'healthcare')); + await waitFor(() => expect(screen.getByTestId('marketplace-empty')).toHaveTextContent(L.noMatchFilters)); + cleanup(); - fireEvent.click(facetButton('businessFunction', 'hr')); - expect(shownCards()).toEqual([]); + await renderPage(() => ({ items: [] }), 'marketplace-empty'); + expect(screen.getByTestId('marketplace-empty')).toHaveTextContent(L.noApprovedYet); + }); +}); + +describe('Marketplace catalog pager (objectstack#31267)', () => { + const PAGE = (n: number) => Array.from({ length: n }, (_, i) => row(`p${i}`, `Package ${i}`, {})); + + it('walks the server total by offset, and a new selection reads from the first page', async () => { + await renderPage(() => ({ items: [SALES, ...PAGE(CATALOG_PAGE_SIZE - 1)], total: 2 * CATALOG_PAGE_SIZE + 5 })); + const info = () => screen.getByTestId('marketplace-pager-info'); + const previous = () => screen.getByTestId('marketplace-pager-previous'); + const next = () => screen.getByTestId('marketplace-pager-next'); + + expect(info()).toHaveTextContent(en.table.pageInfo.replace('{{current}}', '1').replace('{{total}}', '3')); + expect(previous()).toHaveTextContent(en.common.previous); + expect(previous()).toBeDisabled(); + + fireEvent.click(next()); + await expectRead([`limit=${CATALOG_PAGE_SIZE}`, `offset=${CATALOG_PAGE_SIZE}`]); + // The pager waits for the page it asked for before it turns again. + await waitFor(() => expect(next()).toBeEnabled()); + fireEvent.click(next()); + await expectRead([`limit=${CATALOG_PAGE_SIZE}`, `offset=${2 * CATALOG_PAGE_SIZE}`]); + await waitFor(() => expect(info()).toHaveTextContent(en.table.pageInfo.replace('{{current}}', '3').replace('{{total}}', '3'))); + await waitFor(() => expect(previous()).toBeEnabled()); + expect(next()).toBeDisabled(); + + fireEvent.click(previous()); + await expectRead([`limit=${CATALOG_PAGE_SIZE}`, `offset=${CATALOG_PAGE_SIZE}`]); + + fireEvent.click(facetButton('businessFunction', 'marketing')); + await expectRead(['businessFunctions=marketing', ...WINDOW]); + }); + + it('draws no pager while the answer fits one page', async () => { + await renderPage(always(TAXONOMY_CATALOG)); + expect(screen.queryByTestId('marketplace-pager')).toBeNull(); }); }); describe('Marketplace facets hide with no data (objectstack#31011)', () => { it('a catalog serving no taxonomy draws no filter, no function badge and no Beta badge', async () => { - await renderPage([ + await renderPage(always([ row('plain', 'Plain App', {}), row('nulls', 'Null App', { business_functions: null, industries: null, solution_type: null, maturity: null }), - ]); + ])); expect(screen.queryByTestId('marketplace-facets')).toBeNull(); expect(screen.queryByTestId('marketplace-card-function')).toBeNull(); @@ -241,21 +330,31 @@ describe('Marketplace facets hide with no data (objectstack#31011)', () => { expect(shownCards()).toEqual(['com.acme.plain', 'com.acme.nulls']); }); - it('industries and solution type hide while no loaded listing carries one', async () => { - await renderPage([ + it('industries and solution type hide while no answered listing carries one', async () => { + await renderPage(always([ row('a', 'A', { business_functions: ['legal'] }), row('b', 'B', { business_functions: ['it'], industries: [] }), - ]); + ])); expect(screen.getByRole('group', { name: L.facet.businessFunction })).toBeInTheDocument(); expect(screen.queryByRole('group', { name: L.facet.industry })).toBeNull(); expect(screen.queryByRole('group', { name: L.facet.solutionType })).toBeNull(); }); + it('a drawn facet stays drawn when a selection narrows the page to listings without it', async () => { + const BARE = row('bare', 'Bare Retail', { industries: ['retail'] }); + await renderPage((query) => ({ items: query.has('industries') ? [BARE] : TAXONOMY_CATALOG })); + + fireEvent.click(facetButton('industry', 'retail')); + await waitFor(() => expect(shownCards()).toEqual(['com.acme.bare'])); + expect(screen.getByRole('group', { name: L.facet.businessFunction })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: L.facet.solutionType })).toBeInTheDocument(); + }); + it('a value outside the spec vocabulary is drawn nowhere', async () => { - await renderPage([ + await renderPage(always([ row('legacy', 'Legacy CRM', { business_functions: ['crm'], industries: ['general'], solution_type: 'theme' }), - ]); + ])); expect(screen.queryByTestId('marketplace-facets')).toBeNull(); expect(screen.queryByTestId('marketplace-card-function')).toBeNull(); @@ -263,17 +362,14 @@ describe('Marketplace facets hide with no data (objectstack#31011)', () => { }); it('a list column that is not an array reads as no values, and the catalog still renders', async () => { - await renderPage([ + await renderPage(always([ row('string', 'String Columns', { business_functions: 'sales', industries: 'retail' }), row('object', 'Object Columns', { business_functions: { 0: 'hr' }, industries: 7 }), row('array', 'Array Columns', { business_functions: ['legal'] }), - ]); + ])); expect(shownCards()).toEqual(['com.acme.string', 'com.acme.object', 'com.acme.array']); - const fn = screen.getByRole('group', { name: L.facet.businessFunction }); - expect(within(fn).getAllByRole('button').map((b) => b.getAttribute('data-testid'))).toEqual([ - 'marketplace-facet-businessFunction-legal', - ]); + expect(screen.getByRole('group', { name: L.facet.businessFunction })).toBeInTheDocument(); expect(screen.queryByRole('group', { name: L.facet.industry })).toBeNull(); expect(within(screen.getByTestId('marketplace-card-com.acme.string')).queryByTestId('marketplace-card-function')) .toBeNull(); @@ -282,7 +378,7 @@ describe('Marketplace facets hide with no data (objectstack#31011)', () => { describe('Marketplace card badges (objectstack#31011)', () => { it('a card shows its primary business function, the first one listed', async () => { - await renderPage(TAXONOMY_CATALOG); + await renderPage(always(TAXONOMY_CATALOG)); const billing = screen.getByTestId('marketplace-card-com.acme.billing'); expect(within(billing).getByTestId('marketplace-card-function')).toHaveTextContent(L.businessFunction.finance); @@ -292,7 +388,7 @@ describe('Marketplace card badges (objectstack#31011)', () => { }); it('Beta shows on a beta listing only', async () => { - await renderPage(TAXONOMY_CATALOG); + await renderPage(always(TAXONOMY_CATALOG)); expect(screen.getAllByTestId('marketplace-card-beta')).toHaveLength(1); const hiring = screen.getByTestId('marketplace-card-com.acme.hiring'); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx index 3788cd48812..ba3845ed70a 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx @@ -11,9 +11,12 @@ * the zh pack already uses for identifiers. `en` and the eight other packs * dropped the same jargon in objectui#10969: `en` now names the "app ID". * - * The placeholder is only honest if the search matches what it names, so this - * suite measures that too, through the page's own filter: the display name and - * the identifier each find the package, and text that is neither does not. + * The placeholder is only honest if the search matches what it names. The + * catalog list door does the matching (objectstack#31267): its `q` matches the + * display name and the manifest id, `catalogRowMatches` in + * `@objectstack/cloud-connection`'s marketplace proxy. So this suite measures + * the console's half: the box's text reaches the list read as `q`, trimmed, + * from the first page, and the cards drawn are the door's answer. * * Harness: `MarketplacePage.guardOrder.test.tsx`'s — the real runtime-config * boot, an admin viewer, the API module stubbed — with the REAL @@ -22,7 +25,7 @@ */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn(), @@ -43,8 +46,13 @@ const PKG = { latest_version: null, }; +/** The door's answer to a read: the package, unless the text names neither its name nor its id. */ +const listMarketplacePackages = vi.fn(async (params: { q?: string } = {}) => ({ + items: params.q === 'no-such-package' ? [] : [PKG], +})); + vi.mock('../marketplaceApi', () => ({ - listMarketplacePackages: async () => ({ items: [PKG] }), + listMarketplacePackages: (params: { q?: string }) => listMarketplacePackages(params), listLocalInstalls: async () => [], listOrgPackages: async () => ({ items: [] }), listInstalledPackages: async () => ({ items: [] }), @@ -63,6 +71,7 @@ const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; beforeEach(async () => { + listMarketplacePackages.mockClear(); resetRuntimeConfigForTesting(); vi.stubGlobal( 'fetch', @@ -109,20 +118,23 @@ describe('MarketplacePage search placeholder (objectui#10900)', () => { expect(search.getAttribute('placeholder')).not.toMatch(/manifest/i); }); - it('the search matches the name and the identifier the placeholder names', async () => { + it('the search sends the text the placeholder asks for to the list door, which matches it', async () => { renderIn(ZH); await screen.findByTestId(`marketplace-card-${PKG.manifest_id}`); const search = screen.getByLabelText('搜索市场应用'); + const lastRead = () => listMarketplacePackages.mock.calls.at(-1)![0]; - // The name only — the identifier does not contain it. + // The name, then the identifier: each reaches the read as `q`, trimmed. fireEvent.change(search, { target: { value: 'service desk' } }); + await waitFor(() => expect(lastRead()).toMatchObject({ q: 'service desk', offset: 0 })); expect(screen.getByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeInTheDocument(); - // The identifier only — the name and description do not contain it. - fireEvent.change(search, { target: { value: 'com.acme' } }); + fireEvent.change(search, { target: { value: ' com.acme ' } }); + await waitFor(() => expect(lastRead()).toMatchObject({ q: 'com.acme', offset: 0 })); expect(screen.getByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeInTheDocument(); + // Text that names neither: the door answers nothing, and nothing is drawn. fireEvent.change(search, { target: { value: 'no-such-package' } }); - expect(screen.queryByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeNull(); + await waitFor(() => expect(screen.queryByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeNull()); }); }); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/marketplaceApi.listQuery-31267.test.ts b/ui/packages/app-shell/src/console/marketplace/__tests__/marketplaceApi.listQuery-31267.test.ts new file mode 100644 index 00000000000..c74c23c5a36 --- /dev/null +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/marketplaceApi.listQuery-31267.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `listMarketplacePackages` spells the catalog list read the way + * `GET /api/v1/marketplace/packages` reads it (objectstack#31267): the spec's + * plural facet keys, each ONE comma-joined parameter, `maturity` as one value, + * and a filter that selects nothing left out. The door refuses an empty facet, + * a repeated facet key and the retired `category` with 400 + * `VALIDATION_ERROR`, so the read must send none of them. + * + * The page's own reads go through `MarketplacePage.facets-31011.test.tsx`; + * this file holds `maturity`, which the page selects nowhere. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { listMarketplacePackages } from '../marketplaceApi'; + +let requested: URL[] = []; + +function stubCatalog() { + requested = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + requested.push(new URL(String(input), 'http://console.test')); + return new Response(JSON.stringify({ success: true, data: { items: [], total: 0, limit: 24, offset: 0 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }), + ); +} + +const sent = () => requested.map((url) => `${url.pathname}${url.search}`); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('listMarketplacePackages — the list read (objectstack#31267)', () => { + it('sends each facet as one comma-joined parameter, and maturity as one value', async () => { + stubCatalog(); + await listMarketplacePackages({ + q: 'crm', + businessFunctions: ['sales', 'marketing'], + industries: ['retail'], + solutionTypes: ['app', 'template'], + maturity: 'beta', + limit: 24, + offset: 48, + }); + expect(sent()).toEqual([ + '/api/v1/marketplace/packages?q=crm&businessFunctions=sales%2Cmarketing&industries=retail' + + '&solutionTypes=app%2Ctemplate&maturity=beta&limit=24&offset=48', + ]); + const query = requested[0]!.searchParams; + expect(query.getAll('businessFunctions')).toEqual(['sales,marketing']); + expect(query.getAll('solutionTypes')).toEqual(['app,template']); + }); + + it('leaves out a filter that selects nothing, and never sends category', async () => { + stubCatalog(); + await listMarketplacePackages({ businessFunctions: [], industries: [], solutionTypes: [], limit: 24, offset: 0 }); + expect(sent()).toEqual(['/api/v1/marketplace/packages?limit=24&offset=0']); + await listMarketplacePackages(); + expect(sent()[1]).toBe('/api/v1/marketplace/packages'); + for (const url of requested) expect(url.searchParams.has('category')).toBe(false); + }); +}); diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts index 2d34b21e7a3..c770f203cde 100644 --- a/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts +++ b/ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts @@ -249,9 +249,43 @@ async function call(path: string, init?: RequestInit): Promise { return (payload?.data ?? payload) as T; } -export async function listMarketplacePackages(params: { q?: string; limit?: number; offset?: number } = {}): Promise { +/** + * One read of the catalog list: the search text, the taxonomy filters and the + * page window, as `GET /api/v1/marketplace/packages` takes them. + * + * The filter keys are `MarketplaceSearchRequestSchema`'s + * (`@objectstack/spec/marketplace`): `businessFunctions`, `industries` and + * `solutionTypes` (plural, each a list) and `maturity` (one value). The server + * applies them OR inside one facet and AND across them, before it pages. + */ +export interface MarketplaceListParams { + q?: string; + limit?: number; + offset?: number; + businessFunctions?: readonly MarketplaceBusinessFunction[]; + industries?: readonly MarketplaceIndustry[]; + solutionTypes?: readonly MarketplaceSolutionType[]; + maturity?: PackageMaturity; +} + +/** The list-valued filters, each sent as one comma-joined parameter. */ +const LIST_FILTERS = ['businessFunctions', 'industries', 'solutionTypes'] as const; + +/** + * Read one page of the catalog. A list filter goes out as ONE comma-joined + * parameter (`?businessFunctions=sales,marketing`), and a filter that selects + * nothing is left out rather than sent empty: the door refuses an empty facet, + * a repeated facet key and the retired `category` with 400 `VALIDATION_ERROR`, + * and this read sends none of the three. + */ +export async function listMarketplacePackages(params: MarketplaceListParams = {}): Promise { const usp = new URLSearchParams(); if (params.q) usp.set('q', params.q); + for (const key of LIST_FILTERS) { + const values = params[key]; + if (values && values.length > 0) usp.set(key, values.join(',')); + } + if (params.maturity) usp.set('maturity', params.maturity); if (params.limit != null) usp.set('limit', String(params.limit)); if (params.offset != null) usp.set('offset', String(params.offset)); const qs = usp.toString(); diff --git a/ui/packages/app-shell/src/console/marketplace/marketplaceTaxonomy.ts b/ui/packages/app-shell/src/console/marketplace/marketplaceTaxonomy.ts index 1430ecd5a03..93fc60c6751 100644 --- a/ui/packages/app-shell/src/console/marketplace/marketplaceTaxonomy.ts +++ b/ui/packages/app-shell/src/console/marketplace/marketplaceTaxonomy.ts @@ -8,9 +8,11 @@ * (`marketplace-taxonomy-labels-31011.test.ts` in `@object-ui/i18n`), which iterates * the same `.options`. * - * Filtering semantics are the spec's (`taxonomy.zod.ts`): OR inside one facet, - * AND across facets. Maturity is a flag, not a facet: it draws a Beta badge and - * filters nothing. + * Filtering is the catalog list door's (`GET /api/v1/marketplace/packages`), + * with the spec's semantics (`taxonomy.zod.ts`): OR inside one facet, AND across + * facets, applied on the server before it pages. The console sends the + * selection (`facetListParams`) and filters nothing itself. Maturity is a flag, + * not a facet: it draws a Beta badge, and the console selects no maturity. */ import { @@ -22,7 +24,7 @@ import { type MarketplaceIndustry, type MarketplaceSolutionType, } from '@objectstack/spec/marketplace'; -import type { MarketplacePackageSummary } from './marketplaceApi.js'; +import type { MarketplaceListParams, MarketplacePackageSummary } from './marketplaceApi.js'; /** The spec's business functions, in the spec's order. */ export const BUSINESS_FUNCTIONS: readonly MarketplaceBusinessFunction[] = MarketplaceBusinessFunctionSchema.options; @@ -85,89 +87,59 @@ export const EMPTY_FACET_SELECTION: FacetSelection = { solutionType: null, }; -export type FacetName = 'businessFunctions' | 'industries' | 'solutionType'; - /** - * The solution type a row is filtered and counted under: its own, or the - * spec's default (`app`) when it declares none — the spec's reading of an - * absent key. + * The solution type a row is read under: its own, or the spec's default + * (`app`) when it declares none — the spec's reading of an absent key, and the + * one the catalog list door filters by (a row without the column passes + * `solutionTypes=app`). */ export function effectiveSolutionType(taxonomy: ListingTaxonomy): MarketplaceSolutionType { return taxonomy.solutionType ?? DEFAULT_SOLUTION_TYPE; } /** - * Whether a row passes every facet except `skip`: OR inside a facet (the row - * carries any selected value), AND across facets. `skip` is how a facet counts - * its own values against the OTHER facets' selection. + * The list read's filters for a selection. The server filters and pages (OR + * inside a facet, AND across facets), so the selection travels as the query: + * each facet's values in the spec's order, and a facet with nothing selected + * left out — the door refuses an empty facet. */ -export function matchesFacets(taxonomy: ListingTaxonomy, selection: FacetSelection, skip?: FacetName): boolean { - if (skip !== 'businessFunctions' && selection.businessFunctions.size > 0 - && !taxonomy.businessFunctions.some((v) => selection.businessFunctions.has(v))) return false; - if (skip !== 'industries' && selection.industries.size > 0 - && !taxonomy.industries.some((v) => selection.industries.has(v))) return false; - if (skip !== 'solutionType' && selection.solutionType !== null - && effectiveSolutionType(taxonomy) !== selection.solutionType) return false; - return true; +export function facetListParams( + selection: FacetSelection, +): Pick { + const businessFunctions = BUSINESS_FUNCTIONS.filter((v) => selection.businessFunctions.has(v)); + const industries = INDUSTRIES.filter((v) => selection.industries.has(v)); + return { + ...(businessFunctions.length > 0 ? { businessFunctions } : {}), + ...(industries.length > 0 ? { industries } : {}), + ...(selection.solutionType !== null ? { solutionTypes: [selection.solutionType] } : {}), + }; } -/** One value of a facet as the filter row draws it. */ -export interface FacetOption { - value: T; - /** The rows carrying it, among those that pass the other facets' selection. */ - count: number; +/** + * Which facets the filter row draws. A facet is drawn once a row the catalog + * answered carries a value of it — the solution type once a row declares one — + * and stays drawn from then on, so a selection that narrows the page to rows + * without the facet does not take its buttons away. A catalog that does not + * serve the taxonomy yet draws no filter. + */ +export interface ServedFacets { + businessFunctions: boolean; + industries: boolean; + solutionType: boolean; } -/** The facets the filter row draws; a facet no loaded row carries is `[]` and hides. */ -export interface FacetOptions { - businessFunctions: FacetOption[]; - industries: FacetOption[]; - solutionType: FacetOption[]; -} +export const NO_SERVED_FACETS: ServedFacets = { businessFunctions: false, industries: false, solutionType: false }; -/** - * The facet values to draw, in the spec's order. - * - * - A value is drawn when any LOADED row carries it, or when it is selected, - * so the buttons do not come and go as the search box and the other facets - * narrow the set, and a selection never filters from a button that is gone. - * - Its count is taken over the SEARCHED rows (those the search box keeps) - * that pass the other facets' selection: the rows selecting it would add. - * - A facet no loaded row carries is empty and hides — every facet, until the - * catalog serves the taxonomy. The solution-type facet shows once some row - * declares a type; from then on a row that declares none is counted under - * the spec's default. - */ -export function facetOptions( - loaded: readonly ListingTaxonomy[], - searched: readonly ListingTaxonomy[], - selection: FacetSelection, -): FacetOptions { - const options = ( - values: readonly T[], - carried: (row: ListingTaxonomy) => readonly T[], - selected: (value: T) => boolean, - facet: FacetName, - ): FacetOption[] => { - const present = new Set(); - for (const row of loaded) for (const v of carried(row)) present.add(v); - const counts = new Map(); - for (const row of searched) { - if (!matchesFacets(row, selection, facet)) continue; - for (const v of new Set(carried(row))) counts.set(v, (counts.get(v) ?? 0) + 1); - } - return values - .filter((v) => present.has(v) || selected(v)) - .map((value) => ({ value, count: counts.get(value) ?? 0 })); - }; - const typed = selection.solutionType !== null || loaded.some((row) => row.solutionType !== null); - return { - businessFunctions: options( - BUSINESS_FUNCTIONS, (row) => row.businessFunctions, (v) => selection.businessFunctions.has(v), 'businessFunctions', - ), - industries: options(INDUSTRIES, (row) => row.industries, (v) => selection.industries.has(v), 'industries'), - solutionType: typed - ? options(SOLUTION_TYPES, (row) => [effectiveSolutionType(row)], (v) => selection.solutionType === v, 'solutionType') - : [], +/** `served`, widened by the facets `rows` carry; `served` itself when they add none. */ +export function widenServedFacets(served: ServedFacets, rows: readonly ListingTaxonomy[]): ServedFacets { + const next: ServedFacets = { + businessFunctions: served.businessFunctions || rows.some((row) => row.businessFunctions.length > 0), + industries: served.industries || rows.some((row) => row.industries.length > 0), + solutionType: served.solutionType || rows.some((row) => row.solutionType !== null), }; + return next.businessFunctions === served.businessFunctions + && next.industries === served.industries + && next.solutionType === served.solutionType + ? served + : next; }