mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon, web): enhance plugin input handling and subcategory filtering
- Updated the `pickPluginFields` function to support legacy input aliases, improving compatibility with existing plugin structures. - Added tests to ensure the new input handling works correctly with legacy inputs when resolving plugin snapshots. - Enhanced CSS styles for subcategory elements in the plugin home section, improving visual clarity and user experience. - Introduced new tests for subcategory filtering within the active workflow lane, ensuring accurate plugin categorization. This update significantly improves the plugin management experience by enhancing input handling and refining the categorization system.
This commit is contained in:
parent
26d21a942e
commit
72ecc09326
5 changed files with 122 additions and 17 deletions
|
|
@ -121,9 +121,12 @@ function pickPluginFields(body: Record<string, unknown> | null | undefined) {
|
||||||
&& body.appliedPluginSnapshotId.trim().length > 0
|
&& body.appliedPluginSnapshotId.trim().length > 0
|
||||||
? body.appliedPluginSnapshotId.trim()
|
? body.appliedPluginSnapshotId.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const pluginInputs = body.pluginInputs && typeof body.pluginInputs === 'object'
|
const pluginInputs =
|
||||||
? (body.pluginInputs as Record<string, unknown>)
|
body.pluginInputs && typeof body.pluginInputs === 'object'
|
||||||
: {};
|
? (body.pluginInputs as Record<string, unknown>)
|
||||||
|
: body.inputs && typeof body.inputs === 'object'
|
||||||
|
? (body.inputs as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
const grantCaps = Array.isArray(body.grantCaps)
|
const grantCaps = Array.isArray(body.grantCaps)
|
||||||
? (body.grantCaps as unknown[])
|
? (body.grantCaps as unknown[])
|
||||||
.filter((c): c is string => typeof c === 'string')
|
.filter((c): c is string => typeof c === 'string')
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,22 @@ describe('Plan §8 e2e — daemon-side anchors', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts legacy inputs alias when resolving plugin snapshots', async () => {
|
||||||
|
await installLocal(FIXTURE_DIR);
|
||||||
|
|
||||||
|
const result = resolvePluginSnapshot({
|
||||||
|
db,
|
||||||
|
body: { pluginId: 'sample-plugin', inputs: { topic: 'demo' } },
|
||||||
|
projectId: 'project-1',
|
||||||
|
registry: REGISTRY_VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.ok).toBe(true);
|
||||||
|
if (result?.ok) {
|
||||||
|
expect(result.snapshot.inputs).toMatchObject({ topic: 'demo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('capabilitiesRequiredError envelope shape stays stable for code agents', () => {
|
it('capabilitiesRequiredError envelope shape stays stable for code agents', () => {
|
||||||
const err = capabilitiesRequiredError({
|
const err = capabilitiesRequiredError({
|
||||||
pluginId: 'sample',
|
pluginId: 'sample',
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,10 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
.plugins-home__facet-row--sub {
|
||||||
|
padding-top: 0;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
.plugins-home__facet-row--inline .plugins-home__facet-pills {
|
.plugins-home__facet-row--inline .plugins-home__facet-pills {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
|
@ -328,6 +332,10 @@
|
||||||
color: white;
|
color: white;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
.plugins-home__pill--sub-all {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.plugins-home__empty--filtered {
|
.plugins-home__empty--filtered {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// Facet derivation contract for the plugins-home filter row. The
|
// Facet derivation contract for the plugins-home filter row. The
|
||||||
// home section is driven by a single curated workflow axis (Import /
|
// home section is driven by a single curated workflow axis (Import /
|
||||||
// Create / Export / Refine / Extend). These tests lock the per-record
|
// Create / Export / Refine / Extend) plus scoped subcategories inside
|
||||||
// category extraction, the catalog build (preserves curated order,
|
// the active lane. These tests lock the per-record category extraction,
|
||||||
// drops empty buckets), and the selection-based filtering so the
|
// the catalog build (preserves curated order, drops empty buckets), and
|
||||||
// manifest fields the catalog depends on don't silently drift.
|
// the selection-based filtering so the manifest fields the catalog
|
||||||
|
// depends on don't silently drift.
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
applyFacetSelection,
|
applyFacetSelection,
|
||||||
buildFacetCatalog,
|
buildFacetCatalog,
|
||||||
extractCategories,
|
extractCategories,
|
||||||
|
extractSubcategories,
|
||||||
isFeaturedPlugin,
|
isFeaturedPlugin,
|
||||||
} from '../../src/components/plugins-home/facets';
|
} from '../../src/components/plugins-home/facets';
|
||||||
|
|
||||||
|
|
@ -91,6 +93,31 @@ describe('extractCategories', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractSubcategories', () => {
|
||||||
|
it('maps Create plugins to concrete accumulated buckets', () => {
|
||||||
|
expect(extractSubcategories(fixture({ id: 'a', od: { mode: 'prototype' } }))).toEqual(['prototype']);
|
||||||
|
expect(extractSubcategories(fixture({ id: 'b', od: { mode: 'deck' } }))).toEqual(['deck']);
|
||||||
|
expect(extractSubcategories(fixture({ id: 'c', od: { mode: 'design-system' } }))).toEqual(['design-system']);
|
||||||
|
expect(extractSubcategories(fixture({ id: 'd', tags: ['hyperframes'], od: { mode: 'video' } }))).toEqual(['hyperframes']);
|
||||||
|
expect(extractSubcategories(fixture({ id: 'e', od: { mode: 'image' } }))).toEqual(['image']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Import and Export plugins to lane-scoped child buckets', () => {
|
||||||
|
expect(
|
||||||
|
extractSubcategories(fixture({ id: 'figma', od: { taskKind: 'figma-migration', mode: 'scenario' } })),
|
||||||
|
).toEqual(['from-figma']);
|
||||||
|
expect(
|
||||||
|
extractSubcategories(fixture({ id: 'folder', od: { taskKind: 'code-migration', mode: 'scenario' } })),
|
||||||
|
).toEqual(['from-code']);
|
||||||
|
expect(
|
||||||
|
extractSubcategories(fixture({ id: 'next-export', tags: ['export', 'nextjs', 'react'], od: { mode: 'export' } })),
|
||||||
|
).toEqual(['nextjs']);
|
||||||
|
expect(
|
||||||
|
extractSubcategories(fixture({ id: 'react-export', tags: ['export', 'react'], od: { mode: 'export' } })),
|
||||||
|
).toEqual(['react']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildFacetCatalog', () => {
|
describe('buildFacetCatalog', () => {
|
||||||
it('produces a single category axis with curated order preserved and empty buckets dropped', () => {
|
it('produces a single category axis with curated order preserved and empty buckets dropped', () => {
|
||||||
const plugins = [
|
const plugins = [
|
||||||
|
|
@ -120,6 +147,15 @@ describe('buildFacetCatalog', () => {
|
||||||
expect(catalog.category.find((o) => o.slug === 'export')?.count).toBe(1);
|
expect(catalog.category.find((o) => o.slug === 'export')?.count).toBe(1);
|
||||||
expect(catalog.category.find((o) => o.slug === 'refine')?.count).toBe(1);
|
expect(catalog.category.find((o) => o.slug === 'refine')?.count).toBe(1);
|
||||||
expect(catalog.category.find((o) => o.slug === 'extend')?.count).toBe(1);
|
expect(catalog.category.find((o) => o.slug === 'extend')?.count).toBe(1);
|
||||||
|
expect(catalog.subcategory.create.map((o) => o.slug)).toEqual([
|
||||||
|
'deck',
|
||||||
|
'design-system',
|
||||||
|
'hyperframes',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
]);
|
||||||
|
expect(catalog.subcategory.import.map((o) => o.slug)).toEqual(['from-figma']);
|
||||||
|
expect(catalog.subcategory.export.map((o) => o.slug)).toEqual(['react']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty category axis when no plugin matches a curated bucket', () => {
|
it('returns an empty category axis when no plugin matches a curated bucket', () => {
|
||||||
|
|
@ -144,25 +180,37 @@ describe('applyFacetSelection', () => {
|
||||||
|
|
||||||
it('returns everything when no category is selected', () => {
|
it('returns everything when no category is selected', () => {
|
||||||
expect(
|
expect(
|
||||||
applyFacetSelection(plugins, { category: null }).map((p) => p.id),
|
applyFacetSelection(plugins, { category: null, subcategory: null }).map((p) => p.id),
|
||||||
).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
|
).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by the selected category slug', () => {
|
it('filters by the selected category slug', () => {
|
||||||
expect(
|
expect(
|
||||||
applyFacetSelection(plugins, { category: 'create' }).map((p) => p.id),
|
applyFacetSelection(plugins, { category: 'create', subcategory: null }).map((p) => p.id),
|
||||||
).toEqual(['a', 'b', 'c', 'd', 'e']);
|
).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||||
expect(
|
expect(
|
||||||
applyFacetSelection(plugins, { category: 'export' }).map((p) => p.id),
|
applyFacetSelection(plugins, { category: 'export', subcategory: null }).map((p) => p.id),
|
||||||
).toEqual(['f']);
|
).toEqual(['f']);
|
||||||
expect(
|
expect(
|
||||||
applyFacetSelection(plugins, { category: 'import' }).map((p) => p.id),
|
applyFacetSelection(plugins, { category: 'import', subcategory: null }).map((p) => p.id),
|
||||||
).toEqual(['g']);
|
).toEqual(['g']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters by the selected subcategory inside the selected category', () => {
|
||||||
|
expect(
|
||||||
|
applyFacetSelection(plugins, { category: 'create', subcategory: 'design-system' }).map((p) => p.id),
|
||||||
|
).toEqual(['a']);
|
||||||
|
expect(
|
||||||
|
applyFacetSelection(plugins, { category: 'create', subcategory: 'hyperframes' }).map((p) => p.id),
|
||||||
|
).toEqual(['e']);
|
||||||
|
expect(
|
||||||
|
applyFacetSelection(plugins, { category: 'export', subcategory: 'react' }).map((p) => p.id),
|
||||||
|
).toEqual(['f']);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an empty list when no plugin matches the selected category', () => {
|
it('returns an empty list when no plugin matches the selected category', () => {
|
||||||
expect(
|
expect(
|
||||||
applyFacetSelection(plugins, { category: 'refine' }).map((p) => p.id),
|
applyFacetSelection(plugins, { category: 'refine', subcategory: null }).map((p) => p.id),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@
|
||||||
|
|
||||||
// Plugins home section — UI contract.
|
// Plugins home section — UI contract.
|
||||||
//
|
//
|
||||||
// The section renders a single curated workflow bar (Import / Create /
|
// The section renders a curated workflow bar (Import / Create / Export /
|
||||||
// Export / Refine / Extend). Picking a category filters the grid; the
|
// Refine / Extend) plus a scoped child row for the active lane. Picking
|
||||||
// All pill clears the category filter. A Featured chip sits orthogonal
|
// a category filters the grid; the All pill clears the category filter.
|
||||||
// to the row and overrides the category selection. This suite locks in:
|
// A Featured chip sits orthogonal to the row and overrides the category
|
||||||
|
// selection. This suite locks in:
|
||||||
//
|
//
|
||||||
// 1. The category row renders with All + the curated buckets that
|
// 1. The category row renders with All + the curated buckets that
|
||||||
// have at least one plugin.
|
// have at least one plugin.
|
||||||
// 2. Picking a category filters the grid to plugins in that
|
// 2. Picking a category filters the grid to plugins in that
|
||||||
// bucket.
|
// bucket.
|
||||||
// 3. Concrete create types do not duplicate the Create parent as tabs.
|
// 3. Concrete create types appear in the scoped child row, not as
|
||||||
|
// peers of Create.
|
||||||
// 4. Featured chip overrides the category selection and only shows
|
// 4. Featured chip overrides the category selection and only shows
|
||||||
// curator-promoted plugins.
|
// curator-promoted plugins.
|
||||||
|
|
||||||
|
|
@ -99,6 +101,13 @@ describe('PluginsHomeSection (category bar)', () => {
|
||||||
expect(screen.queryByTestId('plugins-home-pill-category-video')).toBeNull();
|
expect(screen.queryByTestId('plugins-home-pill-category-video')).toBeNull();
|
||||||
expect(screen.queryByTestId('plugins-home-pill-category-image')).toBeNull();
|
expect(screen.queryByTestId('plugins-home-pill-category-image')).toBeNull();
|
||||||
expect(screen.queryByTestId('plugins-home-pill-category-react')).toBeNull();
|
expect(screen.queryByTestId('plugins-home-pill-category-react')).toBeNull();
|
||||||
|
expect(screen.getByTestId('plugins-home-row-subcategory-create')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-prototype')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-deck')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-design-system')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-hyperframes')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-image')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('plugins-home-pill-subcategory-create-video')).toBeTruthy();
|
||||||
// Surface / Type / Scenario rows and the More disclosure are gone.
|
// Surface / Type / Scenario rows and the More disclosure are gone.
|
||||||
expect(screen.queryByTestId('plugins-home-row-surface')).toBeNull();
|
expect(screen.queryByTestId('plugins-home-row-surface')).toBeNull();
|
||||||
expect(screen.queryByTestId('plugins-home-row-type')).toBeNull();
|
expect(screen.queryByTestId('plugins-home-row-type')).toBeNull();
|
||||||
|
|
@ -165,6 +174,27 @@ describe('PluginsHomeSection (category bar)', () => {
|
||||||
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['g']);
|
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['g']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('subcategory pills filter within the active workflow lane', () => {
|
||||||
|
render(
|
||||||
|
<PluginsHomeSection
|
||||||
|
plugins={sample}
|
||||||
|
loading={false}
|
||||||
|
activePluginId={null}
|
||||||
|
pendingApplyId={null}
|
||||||
|
onUse={() => {}}
|
||||||
|
onOpenDetails={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('plugins-home-pill-subcategory-create-deck'));
|
||||||
|
let items = within(screen.getByRole('list')).getAllByRole('listitem');
|
||||||
|
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['f']);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('plugins-home-pill-subcategory-create-all'));
|
||||||
|
fireEvent.click(screen.getByTestId('plugins-home-pill-subcategory-create-hyperframes'));
|
||||||
|
items = within(screen.getByRole('list')).getAllByRole('listitem');
|
||||||
|
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['e']);
|
||||||
|
});
|
||||||
|
|
||||||
it('Extend separates plugin authoring from normal creation', () => {
|
it('Extend separates plugin authoring from normal creation', () => {
|
||||||
render(
|
render(
|
||||||
<PluginsHomeSection
|
<PluginsHomeSection
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue