mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): refactor PluginsHomeSection to implement 3-axis faceted filtering
- Replaced the previous tag-based filtering with a new 3-axis model (SURFACE, TYPE, SCENARIO) for enhanced plugin discovery. - Introduced a new `usePluginFacets` hook to manage the independent selection of facets and their AND-composition. - Updated the `PluginsHomeSection` component to render facet rows and a Featured chip, improving user interaction and layout. - Removed legacy categorization logic and associated files, streamlining the codebase. - Enhanced CSS styles for the new layout and improved visual consistency across the plugins home section. - Added tests to ensure the correctness of facet extraction and filtering logic. This update significantly enhances the user experience by providing a more flexible and intuitive way to filter and discover plugins.
This commit is contained in:
parent
c12c816a44
commit
6cccccdc56
9 changed files with 946 additions and 625 deletions
|
|
@ -1,28 +1,28 @@
|
|||
// Plugins discovery section on Home.
|
||||
//
|
||||
// Renders a single horizontal pill row driven by *plugin tags* rather
|
||||
// than a hard-coded "Image / Video / Examples / Other" taxonomy. Each
|
||||
// plugin's `od.scenario / od.mode / od.surface / od.taskKind / tags`
|
||||
// fields feed `pluginTags()` (`./plugins-home/scenarioTags.ts`), which
|
||||
// produces normalised slugs; the section ranks them by count and
|
||||
// surfaces the top N as filter pills with a "More" overflow expansion.
|
||||
// Renders a 3-axis faceted filter (SURFACE / TYPE / SCENARIO) over
|
||||
// the bundled plugin catalog. Each axis is independent and the
|
||||
// selections compose via AND, so users dial in scope one dimension at
|
||||
// a time. A small Featured chip sits orthogonal to the facet rows for
|
||||
// quick access to curator-promoted picks.
|
||||
//
|
||||
// "Featured" lives at the front of the row when at least one plugin
|
||||
// declares `od.featured: true` (otherwise the first scenario plugin
|
||||
// stands in so the slot stays populated). It is selected by default.
|
||||
//
|
||||
// Categorisation, featured-derivation and filtering are factored out
|
||||
// to `./plugins-home/usePluginCategories.ts` so this file can stay
|
||||
// focused on layout and so unit tests can drive the hook in isolation.
|
||||
// Derivation, catalog building and AND-composition live in
|
||||
// `./plugins-home/facets.ts`; per-axis selection state and the
|
||||
// Featured override live in `./plugins-home/usePluginFacets.ts`. This
|
||||
// file owns layout only.
|
||||
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import { Icon } from './Icon';
|
||||
import { PluginCard } from './plugins-home/PluginCard';
|
||||
import {
|
||||
usePluginCategories,
|
||||
type PluginFilterKey,
|
||||
} from './plugins-home/usePluginCategories';
|
||||
import type { ScenarioTag } from './plugins-home/scenarioTags';
|
||||
usePluginFacets,
|
||||
type FilterMode,
|
||||
} from './plugins-home/usePluginFacets';
|
||||
import type {
|
||||
FacetAxis,
|
||||
FacetOption,
|
||||
FacetSelection,
|
||||
} from './plugins-home/facets';
|
||||
|
||||
interface Props {
|
||||
plugins: InstalledPluginRecord[];
|
||||
|
|
@ -45,168 +45,251 @@ export function PluginsHomeSection({
|
|||
visiblePlugins,
|
||||
featuredList,
|
||||
filtered,
|
||||
filter,
|
||||
setFilter,
|
||||
visibleTags,
|
||||
overflowTags,
|
||||
showOverflow,
|
||||
toggleOverflow,
|
||||
catalog,
|
||||
selection,
|
||||
pickFacet,
|
||||
clearFacets,
|
||||
hasActiveFacet,
|
||||
mode,
|
||||
setMode,
|
||||
totalVisible,
|
||||
} = usePluginCategories({ plugins });
|
||||
} = usePluginFacets({ plugins });
|
||||
|
||||
return (
|
||||
<section className="plugins-home" data-testid="plugins-home-section">
|
||||
<header className="plugins-home__head">
|
||||
<h2 className="plugins-home__title">Plugins</h2>
|
||||
<div className="plugins-home__heading">
|
||||
<h2 className="plugins-home__title">Plugins</h2>
|
||||
<p className="plugins-home__subtitle">
|
||||
Built-in catalog — pick one to load a starter prompt, or type freely above.
|
||||
</p>
|
||||
</div>
|
||||
<span className="plugins-home__count">
|
||||
{loading ? '…' : `${totalVisible} installed`}
|
||||
{loading ? '…' : `${filtered.length} of ${totalVisible}`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="plugins-home__empty">Loading plugins…</div>
|
||||
<div className="plugins-home__empty">Loading catalog…</div>
|
||||
) : visiblePlugins.length === 0 ? (
|
||||
<div className="plugins-home__empty">
|
||||
No plugins installed. Install one with{' '}
|
||||
<code>od plugin install <source></code>.
|
||||
Catalog is empty. Bundled plugins ship with Open Design and should appear
|
||||
here automatically — try restarting the daemon if this persists.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FilterRow
|
||||
filter={filter}
|
||||
<ModeRow
|
||||
mode={mode}
|
||||
featuredCount={featuredList.length}
|
||||
totalVisible={totalVisible}
|
||||
visibleTags={visibleTags}
|
||||
overflowTags={overflowTags}
|
||||
showOverflow={showOverflow}
|
||||
onPick={setFilter}
|
||||
onToggleOverflow={toggleOverflow}
|
||||
hasActiveFacet={hasActiveFacet}
|
||||
onModeChange={setMode}
|
||||
onClearFacets={clearFacets}
|
||||
/>
|
||||
<FacetTable
|
||||
catalog={catalog}
|
||||
selection={selection}
|
||||
totalVisible={totalVisible}
|
||||
onPick={pickFacet}
|
||||
/>
|
||||
|
||||
<div className="plugins-home__grid" role="list">
|
||||
{filtered.map((p) => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
record={p}
|
||||
isActive={activePluginId === p.id}
|
||||
isPending={pendingApplyId === p.id}
|
||||
pendingAny={pendingApplyId !== null}
|
||||
isFeatured={featuredList.some((f) => f.id === p.id)}
|
||||
onUse={onUse}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="plugins-home__empty plugins-home__empty--filtered">
|
||||
No plugins match the current filters.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__linkbtn"
|
||||
onClick={clearFacets}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugins-home__grid" role="list">
|
||||
{filtered.map((p) => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
record={p}
|
||||
isActive={activePluginId === p.id}
|
||||
isPending={pendingApplyId === p.id}
|
||||
pendingAny={pendingApplyId !== null}
|
||||
isFeatured={featuredList.some((f) => f.id === p.id)}
|
||||
onUse={onUse}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterRowProps {
|
||||
filter: PluginFilterKey;
|
||||
interface ModeRowProps {
|
||||
mode: FilterMode;
|
||||
featuredCount: number;
|
||||
totalVisible: number;
|
||||
visibleTags: ScenarioTag[];
|
||||
overflowTags: ScenarioTag[];
|
||||
showOverflow: boolean;
|
||||
onPick: (key: PluginFilterKey) => void;
|
||||
onToggleOverflow: () => void;
|
||||
hasActiveFacet: boolean;
|
||||
onModeChange: (next: FilterMode) => void;
|
||||
onClearFacets: () => void;
|
||||
}
|
||||
|
||||
function FilterRow({
|
||||
filter,
|
||||
// Tiny strip above the facet table: Featured override + a clear-link
|
||||
// when at least one facet is active. Kept compact so the SURFACE row
|
||||
// is what the eye lands on first.
|
||||
function ModeRow({
|
||||
mode,
|
||||
featuredCount,
|
||||
totalVisible,
|
||||
visibleTags,
|
||||
overflowTags,
|
||||
showOverflow,
|
||||
onPick,
|
||||
onToggleOverflow,
|
||||
}: FilterRowProps) {
|
||||
// Don't show the row at all when there is nothing meaningful to filter
|
||||
// (only "All" available + no scenario tags). One pill in isolation is
|
||||
// visual noise; the original behaviour matches this.
|
||||
if (featuredCount === 0 && visibleTags.length === 0) return null;
|
||||
const tagsToRender = showOverflow ? [...visibleTags, ...overflowTags] : visibleTags;
|
||||
hasActiveFacet,
|
||||
onModeChange,
|
||||
onClearFacets,
|
||||
}: ModeRowProps) {
|
||||
return (
|
||||
<div className="plugins-home__filters" role="tablist" aria-label="Plugin categories">
|
||||
<div className="plugins-home__mode" role="group" aria-label="Plugin mode">
|
||||
{featuredCount > 0 ? (
|
||||
<FilterPill
|
||||
slug="featured"
|
||||
label="Featured"
|
||||
count={featuredCount}
|
||||
active={filter === 'featured'}
|
||||
variant="featured"
|
||||
onPick={onPick}
|
||||
/>
|
||||
) : null}
|
||||
<FilterPill
|
||||
slug="all"
|
||||
label="All"
|
||||
count={totalVisible}
|
||||
active={filter === 'all'}
|
||||
onPick={onPick}
|
||||
/>
|
||||
{tagsToRender.map((tag) => (
|
||||
<FilterPill
|
||||
key={tag.slug}
|
||||
slug={tag.slug}
|
||||
label={tag.label}
|
||||
count={tag.count}
|
||||
active={filter === tag.slug}
|
||||
onPick={onPick}
|
||||
/>
|
||||
))}
|
||||
{overflowTags.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__filter plugins-home__filter--more"
|
||||
onClick={onToggleOverflow}
|
||||
data-testid="plugins-home-filter-more"
|
||||
aria-expanded={showOverflow}
|
||||
className={[
|
||||
'plugins-home__chip',
|
||||
'plugins-home__chip--featured',
|
||||
mode === 'featured' ? 'is-active' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => onModeChange(mode === 'featured' ? 'all' : 'featured')}
|
||||
aria-pressed={mode === 'featured'}
|
||||
data-testid="plugins-home-chip-featured"
|
||||
>
|
||||
<span>{showOverflow ? 'Less' : 'More'}</span>
|
||||
<span className="plugins-home__filter-count">
|
||||
{showOverflow ? '−' : `+${overflowTags.length}`}
|
||||
</span>
|
||||
<Icon name="star" size={11} />
|
||||
<span>Featured</span>
|
||||
<span className="plugins-home__chip-count">{featuredCount}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<span className="plugins-home__mode-total">
|
||||
{totalVisible} in catalog
|
||||
</span>
|
||||
{hasActiveFacet ? (
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__linkbtn"
|
||||
onClick={onClearFacets}
|
||||
data-testid="plugins-home-clear"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterPillProps {
|
||||
slug: PluginFilterKey;
|
||||
interface FacetTableProps {
|
||||
catalog: ReturnType<typeof usePluginFacets>['catalog'];
|
||||
selection: FacetSelection;
|
||||
totalVisible: number;
|
||||
onPick: (axis: FacetAxis, slug: string | null) => void;
|
||||
}
|
||||
|
||||
function FacetTable({ catalog, selection, totalVisible, onPick }: FacetTableProps) {
|
||||
return (
|
||||
<div className="plugins-home__facets" role="group" aria-label="Plugin filters">
|
||||
<FacetRow
|
||||
axis="surface"
|
||||
label="Surface"
|
||||
options={catalog.surface}
|
||||
selectedSlug={selection.surface}
|
||||
totalVisible={totalVisible}
|
||||
onPick={onPick}
|
||||
/>
|
||||
<FacetRow
|
||||
axis="type"
|
||||
label="Type"
|
||||
options={catalog.type}
|
||||
selectedSlug={selection.type}
|
||||
totalVisible={totalVisible}
|
||||
onPick={onPick}
|
||||
/>
|
||||
<FacetRow
|
||||
axis="scenario"
|
||||
label="Scenario"
|
||||
options={catalog.scenario}
|
||||
selectedSlug={selection.scenario}
|
||||
totalVisible={totalVisible}
|
||||
onPick={onPick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FacetRowProps {
|
||||
axis: FacetAxis;
|
||||
label: string;
|
||||
options: FacetOption[];
|
||||
selectedSlug: string | null;
|
||||
totalVisible: number;
|
||||
onPick: (axis: FacetAxis, slug: string | null) => void;
|
||||
}
|
||||
|
||||
function FacetRow({ axis, label, options, selectedSlug, totalVisible, onPick }: FacetRowProps) {
|
||||
if (options.length === 0) return null;
|
||||
return (
|
||||
<div className="plugins-home__facet-row" data-testid={`plugins-home-row-${axis}`}>
|
||||
<span className="plugins-home__facet-label">{label}</span>
|
||||
<div className="plugins-home__facet-pills" role="tablist" aria-label={`${label} filter`}>
|
||||
<FacetPill
|
||||
slug={null}
|
||||
label="All"
|
||||
count={totalVisible}
|
||||
active={selectedSlug === null}
|
||||
onPick={(slug) => onPick(axis, slug)}
|
||||
axis={axis}
|
||||
variant="all"
|
||||
/>
|
||||
{options.map((opt) => (
|
||||
<FacetPill
|
||||
key={opt.slug}
|
||||
slug={opt.slug}
|
||||
label={opt.label}
|
||||
count={opt.count}
|
||||
active={selectedSlug === opt.slug}
|
||||
onPick={(slug) => onPick(axis, slug)}
|
||||
axis={axis}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FacetPillProps {
|
||||
axis: FacetAxis;
|
||||
slug: string | null;
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
variant?: 'featured';
|
||||
onPick: (key: PluginFilterKey) => void;
|
||||
variant?: 'all';
|
||||
onPick: (slug: string | null) => void;
|
||||
}
|
||||
|
||||
function FilterPill({ slug, label, count, active, variant, onPick }: FilterPillProps) {
|
||||
const isFeatured = variant === 'featured';
|
||||
function FacetPill({ axis, slug, label, count, active, variant, onPick }: FacetPillProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={[
|
||||
'plugins-home__filter',
|
||||
'plugins-home__pill',
|
||||
active ? 'is-active' : '',
|
||||
isFeatured ? 'plugins-home__filter--featured' : '',
|
||||
variant === 'all' ? 'plugins-home__pill--all' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => onPick(slug)}
|
||||
data-testid={`plugins-home-filter-${slug}`}
|
||||
data-testid={`plugins-home-pill-${axis}-${slug ?? 'all'}`}
|
||||
>
|
||||
{isFeatured ? (
|
||||
<Icon name="star" size={11} className="plugins-home__filter-icon" />
|
||||
) : null}
|
||||
<span>{label}</span>
|
||||
<span className="plugins-home__filter-count">{count}</span>
|
||||
<span className="plugins-home__pill-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
291
apps/web/src/components/plugins-home/facets.ts
Normal file
291
apps/web/src/components/plugins-home/facets.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// Facet derivation for the Plugins home section.
|
||||
//
|
||||
// The home filter row is a 3-axis faceted control modeled on the
|
||||
// surface / type / scenario taxonomy plugin authors already populate
|
||||
// in `open-design.json`:
|
||||
//
|
||||
// - SURFACE ← od.surface (web / image / video / audio)
|
||||
// - TYPE ← od.mode (design-system / deck / prototype / …)
|
||||
// - SCENARIO ← od.scenario (+ a tag whitelist for legacy plugins
|
||||
// that omit the field)
|
||||
//
|
||||
// Centralising the derivation here keeps the categorisation hook pure
|
||||
// and lets tests assert facet membership without touching React. The
|
||||
// hook then composes selections across the three axes via AND.
|
||||
//
|
||||
// Notes for future maintainers:
|
||||
// - All axis values are `slugify`d so any fragment of free-form text
|
||||
// ("Design System", "design_system", "design-system") collapses to
|
||||
// a single bucket — both at derivation time AND when comparing
|
||||
// selections.
|
||||
// - Returning a fixed object shape (instead of `Record<string, …>`)
|
||||
// keeps the React render path branch-free and mirrors the three
|
||||
// pill rows the section paints 1:1.
|
||||
// - Counts in each row reflect the catalog *as a whole*, not the
|
||||
// post-filter slice. We deliberately avoid recomputing counts
|
||||
// after a selection because per-axis counts that "go to zero" as
|
||||
// the user clicks make the row visually noisy and obscure how the
|
||||
// overall catalog is shaped.
|
||||
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
|
||||
export type FacetAxis = 'surface' | 'type' | 'scenario';
|
||||
|
||||
export interface PluginFacets {
|
||||
surface: string[];
|
||||
type: string[];
|
||||
scenario: string[];
|
||||
}
|
||||
|
||||
export interface FacetOption {
|
||||
slug: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FacetCatalog {
|
||||
surface: FacetOption[];
|
||||
type: FacetOption[];
|
||||
scenario: FacetOption[];
|
||||
}
|
||||
|
||||
// Tags that bubble up from low-level plugin metadata and never make
|
||||
// for useful filter chips. Drop them at derivation time so they don't
|
||||
// pollute the SCENARIO row (which falls back to tags when od.scenario
|
||||
// is missing).
|
||||
const NOISE = new Set<string>([
|
||||
'first-party',
|
||||
'third-party',
|
||||
'phase-7',
|
||||
'phase-1',
|
||||
'atom',
|
||||
'bundle',
|
||||
'scenario',
|
||||
'plugin',
|
||||
'untitled',
|
||||
]);
|
||||
|
||||
// Curated SCENARIO vocabulary — a focused set of business / use-case
|
||||
// tags so the SCENARIO row reads as a domain picker rather than a
|
||||
// free-form tag cloud. Anything outside this set still surfaces via
|
||||
// `od.scenario` (the manifest field) which is authored intentionally.
|
||||
const SCENARIO_TAG_WHITELIST = new Set<string>([
|
||||
'engineering',
|
||||
'product',
|
||||
'design',
|
||||
'marketing',
|
||||
'sales',
|
||||
'finance',
|
||||
'hr',
|
||||
'operations',
|
||||
'education',
|
||||
'personal',
|
||||
'general',
|
||||
'creator',
|
||||
'healthcare',
|
||||
'planning',
|
||||
'legal',
|
||||
'support',
|
||||
'developer-tools',
|
||||
'e-commerce-retail',
|
||||
'media-consumer',
|
||||
'productivity-saas',
|
||||
'creative-artistic',
|
||||
'professional-corporate',
|
||||
'design-creative',
|
||||
'ai-llm',
|
||||
'social-media-post',
|
||||
'live',
|
||||
'live-artifacts',
|
||||
'orbit',
|
||||
]);
|
||||
|
||||
const SURFACE_LABELS: Record<string, string> = {
|
||||
web: 'Web',
|
||||
image: 'Image',
|
||||
video: 'Video',
|
||||
audio: 'Audio',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
'design-system': 'Design system',
|
||||
deck: 'Deck',
|
||||
prototype: 'Prototype',
|
||||
template: 'Template',
|
||||
example: 'Example',
|
||||
utility: 'Utility',
|
||||
image: 'Image',
|
||||
video: 'Video',
|
||||
audio: 'Audio',
|
||||
scenario: 'Scenario',
|
||||
};
|
||||
|
||||
const SCENARIO_LABELS: Record<string, string> = {
|
||||
engineering: 'Engineering',
|
||||
product: 'Product',
|
||||
design: 'Design',
|
||||
marketing: 'Marketing',
|
||||
sales: 'Sales',
|
||||
finance: 'Finance',
|
||||
hr: 'HR',
|
||||
operations: 'Operations',
|
||||
education: 'Education',
|
||||
personal: 'Personal',
|
||||
general: 'General',
|
||||
creator: 'Creator',
|
||||
healthcare: 'Healthcare',
|
||||
planning: 'Planning',
|
||||
legal: 'Legal',
|
||||
support: 'Support',
|
||||
'developer-tools': 'Developer tools',
|
||||
'e-commerce-retail': 'E-commerce',
|
||||
'media-consumer': 'Media & consumer',
|
||||
'productivity-saas': 'Productivity',
|
||||
'creative-artistic': 'Creative',
|
||||
'professional-corporate': 'Corporate',
|
||||
'design-creative': 'Design & creative',
|
||||
'ai-llm': 'AI & LLM',
|
||||
'social-media-post': 'Social',
|
||||
live: 'Live',
|
||||
'live-artifacts': 'Live artifact',
|
||||
orbit: 'Orbit',
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '');
|
||||
}
|
||||
|
||||
function humanise(slug: string): string {
|
||||
return slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function manifestField(record: InstalledPluginRecord, key: string): string | undefined {
|
||||
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
|
||||
const v = od[key];
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
}
|
||||
|
||||
function manifestTags(record: InstalledPluginRecord): string[] {
|
||||
const raw = record.manifest?.tags ?? [];
|
||||
return raw
|
||||
.map((t) => slugify(String(t)))
|
||||
.filter((t) => t && !NOISE.has(t));
|
||||
}
|
||||
|
||||
// Per-plugin facet derivation. The result lists each axis as a
|
||||
// possibly-empty array because:
|
||||
// - SURFACE may be missing on legacy manifests (we fall back to
|
||||
// scanning tags for one of the four known surface words).
|
||||
// - TYPE is single-valued in our manifests but represented as an
|
||||
// array so the filter compose path can stay uniform.
|
||||
// - SCENARIO can be multi-valued: `od.scenario` plus any tag in
|
||||
// SCENARIO_TAG_WHITELIST.
|
||||
export function extractFacets(record: InstalledPluginRecord): PluginFacets {
|
||||
const surface = new Set<string>();
|
||||
const type = new Set<string>();
|
||||
const scenario = new Set<string>();
|
||||
|
||||
const surfaceRaw = manifestField(record, 'surface');
|
||||
if (surfaceRaw) {
|
||||
const s = slugify(surfaceRaw);
|
||||
if (SURFACE_LABELS[s]) surface.add(s);
|
||||
}
|
||||
|
||||
const tags = manifestTags(record);
|
||||
if (surface.size === 0) {
|
||||
for (const t of tags) {
|
||||
if (SURFACE_LABELS[t]) {
|
||||
surface.add(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modeRaw = manifestField(record, 'mode');
|
||||
if (modeRaw) {
|
||||
const m = slugify(modeRaw);
|
||||
if (m && !NOISE.has(m)) type.add(m);
|
||||
}
|
||||
|
||||
const scenarioRaw = manifestField(record, 'scenario');
|
||||
if (scenarioRaw) {
|
||||
const s = slugify(scenarioRaw);
|
||||
if (s && !NOISE.has(s)) scenario.add(s);
|
||||
}
|
||||
for (const t of tags) {
|
||||
if (SCENARIO_TAG_WHITELIST.has(t)) scenario.add(t);
|
||||
}
|
||||
|
||||
return {
|
||||
surface: [...surface],
|
||||
type: [...type],
|
||||
scenario: [...scenario],
|
||||
};
|
||||
}
|
||||
|
||||
function labelFor(axis: FacetAxis, slug: string): string {
|
||||
if (axis === 'surface') return SURFACE_LABELS[slug] ?? humanise(slug);
|
||||
if (axis === 'type') return TYPE_LABELS[slug] ?? humanise(slug);
|
||||
return SCENARIO_LABELS[slug] ?? humanise(slug);
|
||||
}
|
||||
|
||||
function buildAxis(
|
||||
axis: FacetAxis,
|
||||
plugins: InstalledPluginRecord[],
|
||||
): FacetOption[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of plugins) {
|
||||
const facets = extractFacets(p);
|
||||
for (const slug of facets[axis]) {
|
||||
counts.set(slug, (counts.get(slug) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
|
||||
.map(([slug, count]) => ({ slug, label: labelFor(axis, slug), count }));
|
||||
}
|
||||
|
||||
export function buildFacetCatalog(plugins: InstalledPluginRecord[]): FacetCatalog {
|
||||
return {
|
||||
surface: buildAxis('surface', plugins),
|
||||
type: buildAxis('type', plugins),
|
||||
scenario: buildAxis('scenario', plugins),
|
||||
};
|
||||
}
|
||||
|
||||
// Filter a plugin set by an active selection in each axis. `null` in
|
||||
// any axis means "no filter on this axis" (the axis row's "All" pill).
|
||||
// Filters compose via AND across axes.
|
||||
export interface FacetSelection {
|
||||
surface: string | null;
|
||||
type: string | null;
|
||||
scenario: string | null;
|
||||
}
|
||||
|
||||
export function applyFacetSelection(
|
||||
plugins: InstalledPluginRecord[],
|
||||
selection: FacetSelection,
|
||||
): InstalledPluginRecord[] {
|
||||
if (!selection.surface && !selection.type && !selection.scenario) return plugins;
|
||||
return plugins.filter((p) => {
|
||||
const facets = extractFacets(p);
|
||||
if (selection.surface && !facets.surface.includes(selection.surface)) return false;
|
||||
if (selection.type && !facets.type.includes(selection.type)) return false;
|
||||
if (selection.scenario && !facets.scenario.includes(selection.scenario)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
|
||||
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
|
||||
return od.featured === true;
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
// Tag derivation + labelling for the Plugins home section.
|
||||
//
|
||||
// The home filter row is scenario-driven (per the migrate-to-plugins
|
||||
// design): we no longer hard-code 4 buckets (Image / Video / Examples
|
||||
// / Design). Instead every plugin emits a normalised set of tags
|
||||
// (kebab-case slugs) and the section ranks tags by occurrence,
|
||||
// surfacing the top N as pills with a "More" overflow.
|
||||
//
|
||||
// Centralising the derivation here lets the categorisation hook stay
|
||||
// pure and lets tests assert tag membership without touching React.
|
||||
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
|
||||
// Slugs the user only ever sees because they bubble up from low-level
|
||||
// plugin metadata. They never make for useful filter chips on the home
|
||||
// page, so we drop them at derivation time. Keep the list small so an
|
||||
// unexpected tag still reaches the UI for surfacing.
|
||||
const NOISE_TAGS = new Set([
|
||||
'first-party',
|
||||
'third-party',
|
||||
'phase-7',
|
||||
'phase-1',
|
||||
'atom',
|
||||
'bundle',
|
||||
'scenario',
|
||||
'plugin',
|
||||
]);
|
||||
|
||||
// Pretty labels for known scenario slugs. The renderer falls back to
|
||||
// title-casing the slug for anything missing here, so this map only
|
||||
// needs to cover the cases where humanise() would produce something
|
||||
// awkward ("E-Commerce-Retail" instead of "E-commerce & retail").
|
||||
const TAG_LABELS: Record<string, string> = {
|
||||
image: 'Image',
|
||||
video: 'Video',
|
||||
audio: 'Audio',
|
||||
'image-template': 'Image template',
|
||||
'video-template': 'Video template',
|
||||
example: 'Example',
|
||||
'design-system': 'Design system',
|
||||
workflow: 'Workflow',
|
||||
marketing: 'Marketing',
|
||||
dashboard: 'Dashboard',
|
||||
landing: 'Landing',
|
||||
prototype: 'Prototype',
|
||||
mobile: 'Mobile',
|
||||
desktop: 'Desktop',
|
||||
web: 'Web',
|
||||
design: 'Design',
|
||||
engineering: 'Engineering',
|
||||
product: 'Product',
|
||||
sales: 'Sales',
|
||||
finance: 'Finance',
|
||||
hr: 'HR',
|
||||
operations: 'Operations',
|
||||
support: 'Support',
|
||||
'e-commerce-retail': 'E-commerce',
|
||||
'developer-tools': 'Developer tools',
|
||||
'new-generation': 'New generation',
|
||||
'code-migration': 'Code migration',
|
||||
'figma-migration': 'Figma migration',
|
||||
'tune-collab': 'Tune & collab',
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '');
|
||||
}
|
||||
|
||||
export function labelForTag(slug: string): string {
|
||||
const known = TAG_LABELS[slug];
|
||||
if (known) return known;
|
||||
return slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Tags the user is most likely to recognise as a "scenario" pill. They
|
||||
// get a stable position at the start of the pill row regardless of
|
||||
// raw frequency, so the row never reshuffles when a new plugin lands.
|
||||
const PINNED_ORDER = [
|
||||
'workflow',
|
||||
'example',
|
||||
'image',
|
||||
'video',
|
||||
'design-system',
|
||||
];
|
||||
|
||||
interface ManifestExtras {
|
||||
featured?: boolean;
|
||||
surface?: string;
|
||||
}
|
||||
|
||||
function manifestExtras(record: InstalledPluginRecord): ManifestExtras {
|
||||
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
featured: od.featured === true,
|
||||
surface: typeof od.surface === 'string' ? (od.surface as string) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Derive the set of normalised scenario tags for a single plugin. Two
|
||||
// invariants the consumer can rely on:
|
||||
//
|
||||
// 1. Every multi-stage scenario plugin gets a synthetic `workflow`
|
||||
// tag so the user can filter scripted pipelines as a group.
|
||||
// 2. `surface` and `mode` are added when present so type-style
|
||||
// categorisation (image / video / design-system) still works
|
||||
// without the renderer hard-coding any of them.
|
||||
export function pluginTags(record: InstalledPluginRecord): string[] {
|
||||
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
|
||||
const raw: Array<string | undefined> = [];
|
||||
for (const t of record.manifest?.tags ?? []) raw.push(String(t));
|
||||
if (typeof od.scenario === 'string') raw.push(od.scenario);
|
||||
if (typeof od.mode === 'string') raw.push(od.mode);
|
||||
const surface = manifestExtras(record).surface;
|
||||
if (surface) raw.push(surface);
|
||||
if (typeof od.taskKind === 'string') raw.push(od.taskKind);
|
||||
if (typeof od.platform === 'string') raw.push(od.platform);
|
||||
|
||||
const pipelineStages =
|
||||
od.pipeline && typeof od.pipeline === 'object'
|
||||
? (od.pipeline as { stages?: unknown[] }).stages
|
||||
: undefined;
|
||||
if (Array.isArray(pipelineStages) && pipelineStages.length > 1) {
|
||||
raw.push('workflow');
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const t of raw) {
|
||||
if (!t) continue;
|
||||
const slug = slugify(t);
|
||||
if (!slug || NOISE_TAGS.has(slug)) continue;
|
||||
if (seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
out.push(slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
|
||||
return manifestExtras(record).featured === true;
|
||||
}
|
||||
|
||||
export interface ScenarioTag {
|
||||
slug: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Sort tags by frequency, with PINNED_ORDER tags floated to the top
|
||||
// (still hidden when they have zero matches). Returns the catalog the
|
||||
// pill row renders verbatim.
|
||||
export function buildTagCatalog(plugins: InstalledPluginRecord[]): ScenarioTag[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const plugin of plugins) {
|
||||
for (const slug of pluginTags(plugin)) {
|
||||
counts.set(slug, (counts.get(slug) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const pinned = PINNED_ORDER.flatMap((slug) => {
|
||||
const count = counts.get(slug);
|
||||
if (!count) return [];
|
||||
return [{ slug, label: labelForTag(slug), count }];
|
||||
});
|
||||
const pinnedSlugs = new Set(PINNED_ORDER);
|
||||
const rest = Array.from(counts.entries())
|
||||
.filter(([slug]) => !pinnedSlugs.has(slug))
|
||||
.sort((a, b) => {
|
||||
if (a[1] !== b[1]) return b[1] - a[1];
|
||||
return a[0].localeCompare(b[0]);
|
||||
})
|
||||
.map(([slug, count]) => ({ slug, label: labelForTag(slug), count }));
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
// Pure categorisation hook for the Plugins home section.
|
||||
//
|
||||
// Encapsulates the "what tags exist, who's featured, which subset is
|
||||
// active" computation so the section component can stay focused on
|
||||
// rendering. Returning derived state from one place also makes the
|
||||
// behaviour straightforward to unit-test (see
|
||||
// tests/components/plugins-home-categories.test.ts).
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import { buildTagCatalog, isFeaturedPlugin, pluginTags, type ScenarioTag } from './scenarioTags';
|
||||
|
||||
// Synthetic filter keys for the always-present pills at the start of
|
||||
// the row. Real tag slugs follow these in the pill array; the union
|
||||
// type below lets the component compare-and-render with one variable.
|
||||
export type PluginFilterKey = 'featured' | 'all' | string;
|
||||
|
||||
// How many scenario tags we show as standalone pills before collapsing
|
||||
// the remainder into a "More" expansion. Sized so the row stays on
|
||||
// one line for typical (~6 type tags + a handful of scenario tags)
|
||||
// catalogs while leaving an obvious overflow signal when the catalog
|
||||
// grows past it.
|
||||
const DEFAULT_VISIBLE_TAGS = 8;
|
||||
|
||||
interface UsePluginCategoriesArgs {
|
||||
plugins: InstalledPluginRecord[];
|
||||
visibleTagLimit?: number;
|
||||
}
|
||||
|
||||
export interface UsePluginCategoriesResult {
|
||||
visiblePlugins: InstalledPluginRecord[];
|
||||
featuredList: InstalledPluginRecord[];
|
||||
filtered: InstalledPluginRecord[];
|
||||
filter: PluginFilterKey;
|
||||
setFilter: (next: PluginFilterKey | null) => void;
|
||||
visibleTags: ScenarioTag[];
|
||||
overflowTags: ScenarioTag[];
|
||||
showOverflow: boolean;
|
||||
toggleOverflow: () => void;
|
||||
totalVisible: number;
|
||||
}
|
||||
|
||||
export function usePluginCategories(
|
||||
args: UsePluginCategoriesArgs,
|
||||
): UsePluginCategoriesResult {
|
||||
const [picked, setPicked] = useState<PluginFilterKey | null>(null);
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
const limit = args.visibleTagLimit ?? DEFAULT_VISIBLE_TAGS;
|
||||
|
||||
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
|
||||
// are not user-facing on the home grid; the original section already
|
||||
// filtered them out and we preserve that contract.
|
||||
const visiblePlugins = useMemo(
|
||||
() => args.plugins.filter((p) => p.manifest?.od?.kind !== 'atom'),
|
||||
[args.plugins],
|
||||
);
|
||||
|
||||
const featuredList = useMemo(() => {
|
||||
const explicit = visiblePlugins.filter(isFeaturedPlugin);
|
||||
if (explicit.length > 0) return explicit;
|
||||
const scenario = visiblePlugins.find((p) => p.manifest?.od?.kind === 'scenario');
|
||||
return scenario ? [scenario] : [];
|
||||
}, [visiblePlugins]);
|
||||
|
||||
const tagCatalog = useMemo(() => buildTagCatalog(visiblePlugins), [visiblePlugins]);
|
||||
const visibleTags = useMemo(() => tagCatalog.slice(0, limit), [tagCatalog, limit]);
|
||||
const overflowTags = useMemo(() => tagCatalog.slice(limit), [tagCatalog, limit]);
|
||||
|
||||
// Default to Featured (if any), else "All". The user's explicit
|
||||
// pick wins as long as it still exists in the catalog after a data
|
||||
// refresh — picking a slug that vanished simply snaps back to the
|
||||
// default rather than rendering an empty grid.
|
||||
const availableFilterKeys = useMemo(() => {
|
||||
const keys = new Set<PluginFilterKey>(['all']);
|
||||
if (featuredList.length > 0) keys.add('featured');
|
||||
for (const tag of tagCatalog) keys.add(tag.slug);
|
||||
return keys;
|
||||
}, [tagCatalog, featuredList.length]);
|
||||
|
||||
const filter: PluginFilterKey =
|
||||
picked && availableFilterKeys.has(picked)
|
||||
? picked
|
||||
: featuredList.length > 0
|
||||
? 'featured'
|
||||
: 'all';
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'featured') return featuredList;
|
||||
if (filter === 'all') return visiblePlugins;
|
||||
return visiblePlugins.filter((p) => pluginTags(p).includes(filter));
|
||||
}, [filter, featuredList, visiblePlugins]);
|
||||
|
||||
function setFilter(next: PluginFilterKey | null): void {
|
||||
setPicked(next);
|
||||
}
|
||||
function toggleOverflow(): void {
|
||||
setShowOverflow((v) => !v);
|
||||
}
|
||||
|
||||
return {
|
||||
visiblePlugins,
|
||||
featuredList,
|
||||
filtered,
|
||||
filter,
|
||||
setFilter,
|
||||
visibleTags,
|
||||
overflowTags,
|
||||
showOverflow,
|
||||
toggleOverflow,
|
||||
totalVisible: visiblePlugins.length,
|
||||
};
|
||||
}
|
||||
106
apps/web/src/components/plugins-home/usePluginFacets.ts
Normal file
106
apps/web/src/components/plugins-home/usePluginFacets.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Faceted categorisation hook for the Plugins home section.
|
||||
//
|
||||
// Replaces the older single-row scenario-tag hook with a 3-axis
|
||||
// SURFACE / TYPE / SCENARIO model. Filters compose via AND across
|
||||
// axes (selecting Web + Slides + Marketing shows only plugins that
|
||||
// match all three). Each axis selection is independent so the user
|
||||
// can dial scope in / out one dimension at a time.
|
||||
//
|
||||
// A small "Featured" toggle sits orthogonally to the facets — when
|
||||
// active it overrides the facet selection and just shows the
|
||||
// curator-promoted plugins. We intentionally make Featured override
|
||||
// rather than AND-compose so a featured pick is never accidentally
|
||||
// hidden behind a still-selected facet pill.
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import {
|
||||
applyFacetSelection,
|
||||
buildFacetCatalog,
|
||||
isFeaturedPlugin,
|
||||
type FacetAxis,
|
||||
type FacetCatalog,
|
||||
type FacetSelection,
|
||||
} from './facets';
|
||||
|
||||
export type FilterMode = 'all' | 'featured';
|
||||
|
||||
interface UsePluginFacetsArgs {
|
||||
plugins: InstalledPluginRecord[];
|
||||
}
|
||||
|
||||
export interface UsePluginFacetsResult {
|
||||
visiblePlugins: InstalledPluginRecord[];
|
||||
featuredList: InstalledPluginRecord[];
|
||||
filtered: InstalledPluginRecord[];
|
||||
catalog: FacetCatalog;
|
||||
selection: FacetSelection;
|
||||
pickFacet: (axis: FacetAxis, slug: string | null) => void;
|
||||
clearFacets: () => void;
|
||||
hasActiveFacet: boolean;
|
||||
mode: FilterMode;
|
||||
setMode: (next: FilterMode) => void;
|
||||
totalVisible: number;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: FacetSelection = {
|
||||
surface: null,
|
||||
type: null,
|
||||
scenario: null,
|
||||
};
|
||||
|
||||
export function usePluginFacets(args: UsePluginFacetsArgs): UsePluginFacetsResult {
|
||||
const [mode, setMode] = useState<FilterMode>('all');
|
||||
const [selection, setSelection] = useState<FacetSelection>(EMPTY_SELECTION);
|
||||
|
||||
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
|
||||
// are not user-facing on the home grid; the original section already
|
||||
// filtered them out and we preserve that contract.
|
||||
const visiblePlugins = useMemo(
|
||||
() => args.plugins.filter((p) => p.manifest?.od?.kind !== 'atom'),
|
||||
[args.plugins],
|
||||
);
|
||||
|
||||
const featuredList = useMemo(
|
||||
() => visiblePlugins.filter(isFeaturedPlugin),
|
||||
[visiblePlugins],
|
||||
);
|
||||
|
||||
const catalog = useMemo(() => buildFacetCatalog(visiblePlugins), [visiblePlugins]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (mode === 'featured') return featuredList;
|
||||
return applyFacetSelection(visiblePlugins, selection);
|
||||
}, [mode, featuredList, visiblePlugins, selection]);
|
||||
|
||||
function pickFacet(axis: FacetAxis, slug: string | null): void {
|
||||
if (mode === 'featured') setMode('all');
|
||||
setSelection((prev) => ({
|
||||
...prev,
|
||||
[axis]: prev[axis] === slug ? null : slug,
|
||||
}));
|
||||
}
|
||||
|
||||
function clearFacets(): void {
|
||||
setSelection(EMPTY_SELECTION);
|
||||
}
|
||||
|
||||
const hasActiveFacet =
|
||||
selection.surface !== null ||
|
||||
selection.type !== null ||
|
||||
selection.scenario !== null;
|
||||
|
||||
return {
|
||||
visiblePlugins,
|
||||
featuredList,
|
||||
filtered,
|
||||
catalog,
|
||||
selection,
|
||||
pickFacet,
|
||||
clearFacets,
|
||||
hasActiveFacet,
|
||||
mode,
|
||||
setMode,
|
||||
totalVisible: visiblePlugins.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,9 +10,21 @@
|
|||
}
|
||||
.plugins-home__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
.plugins-home__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugins-home__subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.plugins-home__title {
|
||||
margin: 0;
|
||||
|
|
@ -42,78 +54,167 @@
|
|||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Category filter pills */
|
||||
.plugins-home__filters {
|
||||
/* ============================================================
|
||||
Faceted filter — three rows (Surface / Type / Scenario) with a
|
||||
leading axis label on the left and pills wrapping on the right.
|
||||
The Featured chip lives in a smaller mode strip above the rows so
|
||||
it reads as orthogonal to the facet selections it overrides.
|
||||
============================================================ */
|
||||
.plugins-home__mode {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.plugins-home__filter {
|
||||
.plugins-home__mode-total {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plugins-home__chip {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.plugins-home__filter:hover {
|
||||
.plugins-home__chip:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.plugins-home__filter.is-active {
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
color: var(--bg-panel);
|
||||
}
|
||||
.plugins-home__filter.is-active .plugins-home__filter-count {
|
||||
color: var(--bg-panel);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.plugins-home__filter-count {
|
||||
.plugins-home__chip-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plugins-home__filter-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
/* Featured pill — stands out in the row to flag the curated entry. */
|
||||
.plugins-home__filter--featured {
|
||||
.plugins-home__chip--featured {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.plugins-home__filter--featured:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.plugins-home__filter--featured.is-active {
|
||||
.plugins-home__chip--featured.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.plugins-home__filter--featured.is-active .plugins-home__filter-count {
|
||||
.plugins-home__chip--featured.is-active .plugins-home__chip-count {
|
||||
color: white;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* "More" pill — toggles the overflow scenario tags into the pill row
|
||||
inline. Styled to read as a meta control (dashed border, muted) so
|
||||
it never competes with a real category for the user's attention. */
|
||||
.plugins-home__filter--more {
|
||||
border-style: dashed;
|
||||
color: var(--text-faint);
|
||||
.plugins-home__linkbtn {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.plugins-home__filter--more:hover {
|
||||
.plugins-home__linkbtn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.plugins-home__facets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.plugins-home__facet-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.plugins-home__facet-label {
|
||||
padding-top: 7px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plugins-home__facet-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.plugins-home__pill {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.plugins-home__pill:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.plugins-home__pill.is-active {
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
color: var(--bg-panel);
|
||||
}
|
||||
.plugins-home__pill.is-active .plugins-home__pill-count {
|
||||
color: var(--bg-panel);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.plugins-home__pill-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.plugins-home__pill--all {
|
||||
background: var(--accent-tint);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
color: var(--accent);
|
||||
}
|
||||
.plugins-home__pill--all.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.plugins-home__pill--all.is-active .plugins-home__pill-count {
|
||||
color: white;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.plugins-home__empty--filtered {
|
||||
border-style: solid;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.plugins-home__facet-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.plugins-home__facet-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card grid */
|
||||
|
|
|
|||
152
apps/web/tests/components/plugins-home-facets.test.ts
Normal file
152
apps/web/tests/components/plugins-home-facets.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Facet derivation contract for the plugins-home filter rows. The
|
||||
// home section is now driven by a 3-axis SURFACE / TYPE / SCENARIO
|
||||
// model rather than a single tag row; these tests lock the per-axis
|
||||
// extraction + AND composition so the manifest fields the catalog
|
||||
// depends on don't silently drift.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import {
|
||||
applyFacetSelection,
|
||||
buildFacetCatalog,
|
||||
extractFacets,
|
||||
isFeaturedPlugin,
|
||||
} from '../../src/components/plugins-home/facets';
|
||||
|
||||
function fixture(overrides: {
|
||||
id: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
od?: Record<string, unknown>;
|
||||
}): InstalledPluginRecord {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title ?? overrides.id,
|
||||
version: '0.1.0',
|
||||
sourceKind: 'bundled',
|
||||
source: '/tmp',
|
||||
trust: 'bundled',
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
manifest: {
|
||||
name: overrides.id,
|
||||
version: '0.1.0',
|
||||
...(overrides.tags ? { tags: overrides.tags } : {}),
|
||||
...(overrides.od ? { od: overrides.od } : {}),
|
||||
},
|
||||
fsPath: '/tmp',
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractFacets', () => {
|
||||
it('reads SURFACE from od.surface', () => {
|
||||
const f = extractFacets(
|
||||
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
|
||||
);
|
||||
expect(f.surface).toEqual(['web']);
|
||||
});
|
||||
|
||||
it('falls back to a known surface in tags when od.surface is missing', () => {
|
||||
const f = extractFacets(
|
||||
fixture({ id: 'a', tags: ['marketing', 'image', 'untitled'], od: { mode: 'image' } }),
|
||||
);
|
||||
expect(f.surface).toEqual(['image']);
|
||||
});
|
||||
|
||||
it('reads TYPE from od.mode and SCENARIO from od.scenario', () => {
|
||||
const f = extractFacets(
|
||||
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
|
||||
);
|
||||
expect(f.type).toEqual(['design-system']);
|
||||
expect(f.scenario).toEqual(['design']);
|
||||
});
|
||||
|
||||
it('augments SCENARIO with whitelisted role tags', () => {
|
||||
const f = extractFacets(
|
||||
fixture({
|
||||
id: 'a',
|
||||
tags: ['marketing', 'engineering', 'random-noise'],
|
||||
od: { surface: 'web', mode: 'prototype', scenario: 'product' },
|
||||
}),
|
||||
);
|
||||
expect(f.scenario).toEqual(expect.arrayContaining(['product', 'marketing', 'engineering']));
|
||||
expect(f.scenario).not.toContain('random-noise');
|
||||
});
|
||||
|
||||
it('drops noise tags from the SCENARIO axis', () => {
|
||||
const f = extractFacets(
|
||||
fixture({
|
||||
id: 'a',
|
||||
tags: ['first-party', 'phase-7', 'untitled', 'marketing'],
|
||||
od: { surface: 'web', mode: 'deck' },
|
||||
}),
|
||||
);
|
||||
expect(f.scenario).toEqual(['marketing']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFacetCatalog', () => {
|
||||
it('produces three axes sorted by count desc with stable secondary order', () => {
|
||||
const plugins = [
|
||||
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
|
||||
fixture({ id: 'b', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
|
||||
fixture({ id: 'c', od: { surface: 'image', mode: 'image', scenario: 'marketing' } }),
|
||||
fixture({ id: 'd', od: { surface: 'video', mode: 'video', scenario: 'marketing' } }),
|
||||
];
|
||||
const catalog = buildFacetCatalog(plugins);
|
||||
expect(catalog.surface.map((o) => o.slug)).toEqual(['web', 'image', 'video']);
|
||||
expect(catalog.surface[0]).toMatchObject({ slug: 'web', label: 'Web', count: 2 });
|
||||
expect(catalog.type.map((o) => o.slug)[0]).toBe('design-system');
|
||||
expect(catalog.scenario.map((o) => o.slug)).toContain('design');
|
||||
expect(catalog.scenario.map((o) => o.slug)).toContain('marketing');
|
||||
});
|
||||
|
||||
it('humanises unknown slugs in the SCENARIO axis labels', () => {
|
||||
const catalog = buildFacetCatalog([
|
||||
fixture({ id: 'a', od: { surface: 'web', mode: 'prototype', scenario: 'mocktail-bar' } }),
|
||||
]);
|
||||
expect(catalog.scenario[0]?.label).toBe('Mocktail Bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFacetSelection', () => {
|
||||
const plugins = [
|
||||
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
|
||||
fixture({ id: 'b', od: { surface: 'web', mode: 'prototype', scenario: 'marketing' } }),
|
||||
fixture({ id: 'c', od: { surface: 'image', mode: 'image', scenario: 'marketing' } }),
|
||||
fixture({ id: 'd', od: { surface: 'video', mode: 'video', scenario: 'engineering' } }),
|
||||
];
|
||||
|
||||
it('returns everything when no axis is selected', () => {
|
||||
expect(
|
||||
applyFacetSelection(plugins, { surface: null, type: null, scenario: null }).map((p) => p.id),
|
||||
).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('AND-composes selections across axes', () => {
|
||||
const out = applyFacetSelection(plugins, {
|
||||
surface: 'web',
|
||||
type: null,
|
||||
scenario: 'marketing',
|
||||
}).map((p) => p.id);
|
||||
expect(out).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('returns an empty list when no plugin satisfies the selection', () => {
|
||||
const out = applyFacetSelection(plugins, {
|
||||
surface: 'video',
|
||||
type: null,
|
||||
scenario: 'design',
|
||||
});
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFeaturedPlugin', () => {
|
||||
it('returns true only for od.featured === true (strict)', () => {
|
||||
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
|
||||
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
|
||||
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// Tag derivation contract for the plugins-home filter row. The home
|
||||
// section ranks plugins by scenario tag rather than the legacy 4
|
||||
// surface buckets (image / video / examples / design); these tests
|
||||
// lock the slug surface so a tag never silently drops out of the
|
||||
// pill row when the manifest schema evolves.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import {
|
||||
buildTagCatalog,
|
||||
isFeaturedPlugin,
|
||||
labelForTag,
|
||||
pluginTags,
|
||||
} from '../../src/components/plugins-home/scenarioTags';
|
||||
|
||||
function fixture(overrides: {
|
||||
id: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
od?: Record<string, unknown>;
|
||||
}): InstalledPluginRecord {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title ?? overrides.id,
|
||||
version: '0.1.0',
|
||||
sourceKind: 'bundled',
|
||||
source: '/tmp',
|
||||
trust: 'bundled',
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
manifest: {
|
||||
name: overrides.id,
|
||||
version: '0.1.0',
|
||||
...(overrides.tags ? { tags: overrides.tags } : {}),
|
||||
...(overrides.od ? { od: overrides.od } : {}),
|
||||
},
|
||||
fsPath: '/tmp',
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pluginTags', () => {
|
||||
it('emits image / image-template / marketing for a wrapped image template', () => {
|
||||
const tags = pluginTags(
|
||||
fixture({
|
||||
id: 'image-template-foo',
|
||||
tags: ['image-template', 'first-party', 'image', 'marketing'],
|
||||
od: { kind: 'scenario', taskKind: 'new-generation', mode: 'image', surface: 'image' },
|
||||
}),
|
||||
);
|
||||
expect(tags).toContain('image');
|
||||
expect(tags).toContain('image-template');
|
||||
expect(tags).toContain('marketing');
|
||||
expect(tags).toContain('new-generation');
|
||||
expect(tags).not.toContain('first-party');
|
||||
});
|
||||
|
||||
it('adds a synthetic workflow tag for multi-stage scenario plugins', () => {
|
||||
const tags = pluginTags(
|
||||
fixture({
|
||||
id: 'od-code-migration',
|
||||
tags: ['scenario', 'first-party', 'code-migration'],
|
||||
od: {
|
||||
kind: 'scenario',
|
||||
taskKind: 'code-migration',
|
||||
pipeline: { stages: [{ id: 'a', atoms: ['x'] }, { id: 'b', atoms: ['y'] }] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tags).toContain('workflow');
|
||||
expect(tags).toContain('code-migration');
|
||||
expect(tags).not.toContain('scenario');
|
||||
});
|
||||
|
||||
it('drops noise tags but keeps surface / scenario / mode', () => {
|
||||
const tags = pluginTags(
|
||||
fixture({
|
||||
id: 'example-saas-landing',
|
||||
tags: ['example', 'first-party', 'phase-7'],
|
||||
od: { kind: 'scenario', mode: 'prototype', scenario: 'marketing', surface: 'web' },
|
||||
}),
|
||||
);
|
||||
expect(tags).toEqual(expect.arrayContaining(['example', 'marketing', 'prototype', 'web']));
|
||||
expect(tags).not.toContain('first-party');
|
||||
expect(tags).not.toContain('phase-7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTagCatalog', () => {
|
||||
it('pins type tags (image / video / design-system) ahead of free-form tags', () => {
|
||||
const catalog = buildTagCatalog([
|
||||
fixture({ id: 'a', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
|
||||
fixture({ id: 'b', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
|
||||
fixture({ id: 'c', tags: ['design-system'], od: { mode: 'design-system', surface: 'web' } }),
|
||||
]);
|
||||
const slugs = catalog.map((t) => t.slug);
|
||||
// 'image' (pinned, count=2) should come before 'marketing' (count=2).
|
||||
expect(slugs.indexOf('image')).toBeLessThan(slugs.indexOf('marketing'));
|
||||
});
|
||||
|
||||
it('orders by count desc within the non-pinned section', () => {
|
||||
const catalog = buildTagCatalog([
|
||||
fixture({ id: 'a', tags: ['marketing'] }),
|
||||
fixture({ id: 'b', tags: ['marketing'] }),
|
||||
fixture({ id: 'c', tags: ['dashboard'] }),
|
||||
]);
|
||||
const slugs = catalog.map((t) => t.slug);
|
||||
expect(slugs.indexOf('marketing')).toBeLessThan(slugs.indexOf('dashboard'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFeaturedPlugin', () => {
|
||||
it('returns true when od.featured === true', () => {
|
||||
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
|
||||
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
|
||||
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labelForTag', () => {
|
||||
it('uses the known dictionary for canonical slugs', () => {
|
||||
expect(labelForTag('image')).toBe('Image');
|
||||
expect(labelForTag('design-system')).toBe('Design system');
|
||||
});
|
||||
it('falls back to title-casing unknown slugs', () => {
|
||||
expect(labelForTag('mocktail-bar')).toBe('Mocktail Bar');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,16 +2,17 @@
|
|||
|
||||
// Plugins home section — UI contract.
|
||||
//
|
||||
// The section is now driven by `usePluginCategories` (tag-based
|
||||
// scenario chips) rather than the legacy 4-bucket taxonomy. This
|
||||
// suite locks in:
|
||||
// The section renders a 3-axis SURFACE / TYPE / SCENARIO faceted
|
||||
// filter and AND-composes selections across axes. A small Featured
|
||||
// chip sits orthogonal to the facet rows. This suite locks in:
|
||||
//
|
||||
// 1. Featured pill is selected by default when at least one plugin
|
||||
// declares `od.featured: true`.
|
||||
// 2. Clicking a scenario tag filters the grid to plugins carrying
|
||||
// that tag.
|
||||
// 3. The "More" pill toggles overflow tags into the row when the
|
||||
// catalog exceeds the visible limit.
|
||||
// 1. All three facet rows render with axis-specific pills.
|
||||
// 2. Picking a Surface pill filters the grid to plugins on that
|
||||
// surface.
|
||||
// 3. Selections compose via AND across axes (Web + Marketing only
|
||||
// shows plugins that match both).
|
||||
// 4. Featured chip overrides the facet selection and only shows
|
||||
// curator-promoted plugins.
|
||||
|
||||
import { describe, expect, it, afterEach, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
|
|
@ -59,16 +60,18 @@ afterEach(() => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PluginsHomeSection (tag-driven)', () => {
|
||||
it('defaults to the Featured pill when a plugin declares featured', () => {
|
||||
const plugins = [
|
||||
makePlugin({ id: 'star', featured: true, mode: 'image', tags: ['image'] }),
|
||||
makePlugin({ id: 'b', mode: 'image', tags: ['image'] }),
|
||||
makePlugin({ id: 'c', mode: 'video', tags: ['video'] }),
|
||||
];
|
||||
const sample: InstalledPluginRecord[] = [
|
||||
makePlugin({ id: 'a', surface: 'web', mode: 'design-system', scenario: 'design' }),
|
||||
makePlugin({ id: 'b', surface: 'web', mode: 'prototype', scenario: 'marketing' }),
|
||||
makePlugin({ id: 'c', surface: 'image', mode: 'image', scenario: 'marketing' }),
|
||||
makePlugin({ id: 'd', surface: 'video', mode: 'video', scenario: 'engineering' }),
|
||||
];
|
||||
|
||||
describe('PluginsHomeSection (faceted)', () => {
|
||||
it('renders the three facet rows and an "All" pill in each', () => {
|
||||
render(
|
||||
<PluginsHomeSection
|
||||
plugins={plugins}
|
||||
plugins={sample}
|
||||
loading={false}
|
||||
activePluginId={null}
|
||||
pendingApplyId={null}
|
||||
|
|
@ -76,23 +79,18 @@ describe('PluginsHomeSection (tag-driven)', () => {
|
|||
onOpenDetails={() => {}}
|
||||
/>,
|
||||
);
|
||||
const featured = screen.getByTestId('plugins-home-filter-featured');
|
||||
expect(featured.getAttribute('aria-selected')).toBe('true');
|
||||
const grid = screen.getByRole('list');
|
||||
const items = within(grid).getAllByRole('listitem');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.getAttribute('data-plugin-id')).toBe('star');
|
||||
expect(screen.getByTestId('plugins-home-row-surface')).toBeTruthy();
|
||||
expect(screen.getByTestId('plugins-home-row-type')).toBeTruthy();
|
||||
expect(screen.getByTestId('plugins-home-row-scenario')).toBeTruthy();
|
||||
expect(screen.getByTestId('plugins-home-pill-surface-all')).toBeTruthy();
|
||||
expect(screen.getByTestId('plugins-home-pill-type-all')).toBeTruthy();
|
||||
expect(screen.getByTestId('plugins-home-pill-scenario-all')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters by a scenario tag when its pill is clicked', () => {
|
||||
const plugins = [
|
||||
makePlugin({ id: 'a', mode: 'image', tags: ['image', 'marketing'] }),
|
||||
makePlugin({ id: 'b', mode: 'video', tags: ['video', 'marketing'] }),
|
||||
makePlugin({ id: 'c', mode: 'prototype', tags: ['prototype', 'dashboard'] }),
|
||||
];
|
||||
it('filters by a Surface pill when clicked', () => {
|
||||
render(
|
||||
<PluginsHomeSection
|
||||
plugins={plugins}
|
||||
plugins={sample}
|
||||
loading={false}
|
||||
activePluginId={null}
|
||||
pendingApplyId={null}
|
||||
|
|
@ -100,20 +98,33 @@ describe('PluginsHomeSection (tag-driven)', () => {
|
|||
onOpenDetails={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('plugins-home-filter-marketing'));
|
||||
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-web'));
|
||||
const items = within(screen.getByRole('list')).getAllByRole('listitem');
|
||||
expect(items.map((i) => i.getAttribute('data-plugin-id')).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('toggles overflow tags into the pill row when "More" is clicked', () => {
|
||||
// Build 12 plugins each tagged with a unique slug — sorted
|
||||
// alphabetically by the catalog so the last one ('zz-late') is
|
||||
// guaranteed to sit in the overflow tail past the default
|
||||
// 8-pill visible window.
|
||||
const prefixes = ['aa', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'zz-late'];
|
||||
const plugins = prefixes.map((p) =>
|
||||
makePlugin({ id: `id-${p}`, tags: [p, 'image'], mode: 'image' }),
|
||||
it('AND-composes Surface + Scenario selections', () => {
|
||||
render(
|
||||
<PluginsHomeSection
|
||||
plugins={sample}
|
||||
loading={false}
|
||||
activePluginId={null}
|
||||
pendingApplyId={null}
|
||||
onUse={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-web'));
|
||||
fireEvent.click(screen.getByTestId('plugins-home-pill-scenario-marketing'));
|
||||
const items = within(screen.getByRole('list')).getAllByRole('listitem');
|
||||
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('Featured chip overrides facet selection and shows only featured plugins', () => {
|
||||
const plugins = [
|
||||
makePlugin({ id: 'star', surface: 'web', mode: 'design-system', scenario: 'design', featured: true }),
|
||||
...sample,
|
||||
];
|
||||
render(
|
||||
<PluginsHomeSection
|
||||
plugins={plugins}
|
||||
|
|
@ -124,10 +135,9 @@ describe('PluginsHomeSection (tag-driven)', () => {
|
|||
onOpenDetails={() => {}}
|
||||
/>,
|
||||
);
|
||||
const more = screen.getByTestId('plugins-home-filter-more');
|
||||
expect(more).toBeTruthy();
|
||||
expect(screen.queryByTestId('plugins-home-filter-zz-late')).toBeNull();
|
||||
fireEvent.click(more);
|
||||
expect(screen.getByTestId('plugins-home-filter-zz-late')).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-image'));
|
||||
fireEvent.click(screen.getByTestId('plugins-home-chip-featured'));
|
||||
const items = within(screen.getByRole('list')).getAllByRole('listitem');
|
||||
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['star']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue