mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): scope Use actions to the plugin detail modal and decouple the Community filter from hero chips (#4037)
* fix(web): remove inline Use actions from Community gallery tiles The Home Community gallery tile grew an inline Use / Use with query split button in its title bar, repeating per card. The gallery tile is a pure live preview: opening the detail modal is where Use and Use with query live. Drop the gallery use menu (and its CSS) and route the visual-home use-with-query e2e flow through the detail modal's split Use button. The rich management layout (PluginsView installed tab) keeps its hover-overlay Use menu. * fix(web): decouple the Community category filter from hero type chips Picking an example-prompt type chip (Prototype / Slide deck / ...) — including the default chip on first paint — force-synced the Community grid's category selection through presetSelection, silently rewriting the user's browsing filter. The two surfaces share a taxonomy but are independent controls: the chip drives what the composer generates, the pills only filter the gallery. Remove the presetSelection plumbing (HomeView memo -> PluginsHomeSection prop -> usePluginFacets effect); the Community grid now boots on All and only moves when its own pills are clicked. * fix(e2e): match the Deck Writer detail dialog by plugin name Deck Writer carries a previewEntry, so its detail surface is the PreviewModal whose dialog is labelled 'Deck Writer preview' — the '... details' label only exists on the scenario detail. Match the dialog on the plugin name so the visual use-with-query flow finds it. --------- Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
co-authored by
qiongyu1999
parent
b057200405
commit
844a0c0af1
@@ -81,7 +81,6 @@ import { PluginDetailsModal } from './PluginDetailsModal';
|
||||
import { HomeTemplatesReveal } from './HomeTemplatesReveal';
|
||||
import { PluginsHomeSection } from './PluginsHomeSection';
|
||||
import type { PluginLoopSubmit } from './PluginLoopHome';
|
||||
import type { FacetSelection } from './plugins-home/facets';
|
||||
import { localizePluginTitle } from './plugins-home/localization';
|
||||
import type { PluginUseAction } from './plugins-home/useActions';
|
||||
import { examplePresetSeedPrompt } from './plugins-home/presetSeedPrompt';
|
||||
@@ -577,20 +576,6 @@ export function HomeView({
|
||||
stagedFiles.length,
|
||||
]);
|
||||
|
||||
// The Home chip rail and the Community grid share a mental
|
||||
// model — "Prototype" up top is the same artifact intent as the
|
||||
// `prototype` slice down below. When the user picks a chip,
|
||||
// we drive the starters' FacetSelection from it so they get a
|
||||
// pre-filtered shelf of templates for the same intent without having
|
||||
// to scroll and re-pick. `pendingChipId` (set on click, before apply
|
||||
// resolves) is preferred over `active?.chipId` so the filter snaps on
|
||||
// the same frame as the click.
|
||||
const presetStartersSelection = useMemo<FacetSelection | null>(() => {
|
||||
const chipId = pendingChipId ?? active?.chipId ?? null;
|
||||
if (!chipId) return null;
|
||||
return facetSelectionForChip(chipId);
|
||||
}, [pendingChipId, active?.chipId]);
|
||||
|
||||
// When the active plugin was bound through a chip, the badge shows
|
||||
// the chip label (e.g. "Prototype") instead of the underlying plugin
|
||||
// record title (e.g. "New generation (default scenario)"). Several
|
||||
@@ -1681,7 +1666,6 @@ export function HomeView({
|
||||
onOpenExternal={handleCommunityOpenExternal}
|
||||
onBrowseRegistry={onBrowseRegistry}
|
||||
preferDefaultFacet={false}
|
||||
presetSelection={presetStartersSelection}
|
||||
cardLayout="gallery"
|
||||
/>
|
||||
</HomeTemplatesReveal>
|
||||
@@ -1812,27 +1796,6 @@ export function shouldShowActivePluginChip(active: ActivePlugin | null): boolean
|
||||
return active.record.id !== defaultPluginIdForChip(active.chipId);
|
||||
}
|
||||
|
||||
// Maps a Home hero chip id to the Community facet slice the
|
||||
// user most likely wants to browse next. The chip rail is intent
|
||||
// ("I want to design a slide deck"); the starters grid is the catalog
|
||||
// for that intent, so pinning the same `deck` slice lets the
|
||||
// user keep scanning examples without re-picking the same artifact
|
||||
// kind in a different control. The list mirrors the `apply-scenario`
|
||||
// and `apply-figma-migration` chip ids in `home-hero/chips.ts`; any
|
||||
// new chip there should add a row here too.
|
||||
function facetSelectionForChip(chipId: string): FacetSelection | null {
|
||||
switch (chipId) {
|
||||
case 'prototype': return { category: 'prototype', subcategory: null };
|
||||
case 'live-artifact': return { category: 'live-artifact', subcategory: null };
|
||||
case 'deck': return { category: 'deck', subcategory: null };
|
||||
case 'image': return { category: 'image', subcategory: null };
|
||||
case 'video': return { category: 'video', subcategory: null };
|
||||
case 'hyperframes': return { category: 'hyperframes', subcategory: null };
|
||||
case 'audio': return { category: 'audio', subcategory: null };
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t']): string {
|
||||
switch (chipId) {
|
||||
case 'prototype': return t('homeHero.chip.prototype');
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useI18n, useT } from '../i18n';
|
||||
import type { PluginShareAction } from '../state/projects';
|
||||
import { Icon } from './Icon';
|
||||
import { PluginCard } from './plugins-home/PluginCard';
|
||||
import { isFeaturedPlugin, type FacetOption, type FacetSelection } from './plugins-home/facets';
|
||||
import { isFeaturedPlugin, type FacetOption } from './plugins-home/facets';
|
||||
import { localizePluginTitle } from './plugins-home/localization';
|
||||
import { usePluginFacets } from './plugins-home/usePluginFacets';
|
||||
import { useSavedPluginIds } from './plugins-home/savedPlugins';
|
||||
@@ -47,12 +47,6 @@ interface Props {
|
||||
) => void;
|
||||
onBrowseRegistry?: () => void;
|
||||
preferDefaultFacet?: boolean;
|
||||
// Optional external selection. When the Home chip rail picks
|
||||
// "Slide deck", HomeView passes { category: 'deck', subcategory:
|
||||
// null } so the Community grid scrolls to the matching
|
||||
// slice instead of staying on its default. The hook only re-applies
|
||||
// when this identity changes, so manual facet clicks still win.
|
||||
presetSelection?: FacetSelection | null;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
emptyMessage?: string;
|
||||
@@ -73,7 +67,6 @@ export function PluginsHomeSection({
|
||||
onPluginShareAction,
|
||||
onBrowseRegistry,
|
||||
preferDefaultFacet = true,
|
||||
presetSelection = null,
|
||||
title,
|
||||
subtitle,
|
||||
emptyMessage,
|
||||
@@ -102,7 +95,6 @@ export function PluginsHomeSection({
|
||||
plugins,
|
||||
savedPluginIds,
|
||||
preferDefaultFacet,
|
||||
presetSelection,
|
||||
locale,
|
||||
});
|
||||
const renderedPlugins = useMemo(
|
||||
|
||||
@@ -136,71 +136,6 @@ export function PluginCard({
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<div
|
||||
className={`plugins-home__gallery-use plugins-home__use-menu${hasQuery ? ' has-options' : ''}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onBlur={(event) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||
setUseMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__gallery-use-main"
|
||||
onClick={() => pickUseAction('use')}
|
||||
disabled={useDisabled}
|
||||
aria-busy={isPending ? 'true' : undefined}
|
||||
data-testid={`plugins-home-use-${record.id}`}
|
||||
>
|
||||
{isPending ? 'Applying...' : 'Use'}
|
||||
</button>
|
||||
{hasQuery ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__gallery-use-more"
|
||||
onClick={() => setUseMenuOpen((open) => !open)}
|
||||
disabled={useDisabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={useMenuOpen}
|
||||
aria-label={`Choose how to use ${title}`}
|
||||
data-testid={`plugins-home-use-menu-${record.id}`}
|
||||
>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
{useMenuOpen ? (
|
||||
<div
|
||||
className="plugins-home__use-menu-list plugins-home__gallery-use-list"
|
||||
role="menu"
|
||||
aria-label={`Use options for ${title}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="plugins-home__use-menu-item"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => pickUseAction('use')}
|
||||
data-testid={`plugins-home-use-context-${record.id}`}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="plugins-home__use-menu-item"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => pickUseAction('use-with-query')}
|
||||
data-testid={`plugins-home-use-with-query-${record.id}`}
|
||||
>
|
||||
Use with query
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{previewSrc ? (
|
||||
<a
|
||||
className="plugins-home__gallery-open"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// override rather than AND-compose so a saved pick is never
|
||||
// accidentally hidden behind a still-selected category pill.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import {
|
||||
applyFacetSelection,
|
||||
@@ -29,13 +29,6 @@ interface UsePluginFacetsArgs {
|
||||
plugins: InstalledPluginRecord[];
|
||||
savedPluginIds?: ReadonlySet<string>;
|
||||
preferDefaultFacet?: boolean;
|
||||
// External selection driven by the Home hero chip rail. When this
|
||||
// value changes to a new (non-null) selection, the hook applies it,
|
||||
// overriding both the user's manual pick and the default-facet
|
||||
// bootstrap. We track the last-applied identity so the user can
|
||||
// still click a different category afterwards without the effect
|
||||
// snapping back on every re-render.
|
||||
presetSelection?: FacetSelection | null;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
@@ -65,7 +58,6 @@ export function usePluginFacets({
|
||||
plugins,
|
||||
savedPluginIds,
|
||||
preferDefaultFacet = true,
|
||||
presetSelection = null,
|
||||
locale,
|
||||
}: UsePluginFacetsArgs): UsePluginFacetsResult {
|
||||
const [mode, setMode] = useState<FilterMode>('all');
|
||||
@@ -76,7 +68,6 @@ export function usePluginFacets({
|
||||
// initializer) handles the realistic case where `args.plugins` is
|
||||
// empty at first paint and arrives a tick later.
|
||||
const [bootstrapped, setBootstrapped] = useState(false);
|
||||
const lastAppliedPresetKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
|
||||
// are not user-facing on the home grid; the original section already
|
||||
@@ -114,22 +105,6 @@ export function usePluginFacets({
|
||||
setBootstrapped(true);
|
||||
}, [bootstrapped, preferDefaultFacet, visiblePlugins.length, catalog]);
|
||||
|
||||
// Sync an externally-driven selection (the Home chip rail) into the
|
||||
// facet state. We only apply a preset once per identity so the user
|
||||
// can still click a different facet pill afterwards without the
|
||||
// effect snapping back. Setting `bootstrapped` here also prevents
|
||||
// the default-facet effect above from overriding the preset on the
|
||||
// first non-empty render.
|
||||
useEffect(() => {
|
||||
if (!presetSelection) return;
|
||||
const key = `${presetSelection.category ?? ''}::${presetSelection.subcategory ?? ''}`;
|
||||
if (lastAppliedPresetKeyRef.current === key) return;
|
||||
lastAppliedPresetKeyRef.current = key;
|
||||
setSelection(presetSelection);
|
||||
setMode((current) => (current === 'saved' ? 'all' : current));
|
||||
setBootstrapped(true);
|
||||
}, [presetSelection]);
|
||||
|
||||
// The visual-appeal sort is applied at `visiblePlugins` derivation
|
||||
// (above), so any downstream `applyFacetSelection` slice preserves
|
||||
// the ranking. We do not re-sort here because filter + featured
|
||||
|
||||
@@ -500,53 +500,6 @@
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.plugins-home__gallery-use {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
.plugins-home__gallery-use-main,
|
||||
.plugins-home__gallery-use-more {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.plugins-home__gallery-use-main {
|
||||
border-radius: 6px 0 0 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.plugins-home__gallery-use-more {
|
||||
border-left: 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
width: 22px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.plugins-home__gallery-use-main:hover,
|
||||
.plugins-home__gallery-use-more:hover {
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
.plugins-home__gallery-use-main:disabled,
|
||||
.plugins-home__gallery-use-more:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.plugins-home__gallery-use-list {
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
.plugins-home__gallery-open {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// Red spec for the example-chip ↔ Community filter decoupling.
|
||||
//
|
||||
// The hero chip rail (Prototype / Slide deck / ...) and the Community
|
||||
// grid expose the same artifact taxonomy, but they are independent
|
||||
// surfaces: picking a chip drives what the composer will generate,
|
||||
// while the Community pills only filter the gallery the user is
|
||||
// browsing. Binding them means any chip interaction (including the
|
||||
// default active chip on first paint) silently rewrites the user's
|
||||
// browsing filter — so the gallery must stay on its own selection.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { HomeView } from '../../src/components/HomeView';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
|
||||
function makeHomePlugin(id: string, mode: string) {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
trust: 'bundled' as const,
|
||||
sourceKind: 'bundled' as const,
|
||||
source: `/tmp/${id}`,
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
fsPath: `/tmp/${id}`,
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
manifest: {
|
||||
name: id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: `${id} fixture`,
|
||||
od: { kind: 'scenario', taskKind: 'new-generation', mode },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PLUGINS = [
|
||||
makeHomePlugin('example-web-prototype', 'prototype'),
|
||||
makeHomePlugin('example-simple-deck', 'deck'),
|
||||
];
|
||||
|
||||
function ariaSelected(testId: string): string | null {
|
||||
return screen.getByTestId(testId).getAttribute('aria-selected');
|
||||
}
|
||||
|
||||
describe('HomeView community filter decoupling', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps the Community category selection independent from the hero type chips', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
||||
if (typeof url === 'string' && url === '/api/plugins') {
|
||||
return new Response(JSON.stringify({ plugins: PLUGINS }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<I18nProvider initial="en">
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={() => undefined}
|
||||
onOpenProject={() => undefined}
|
||||
onViewAllProjects={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Home boots with a default active type chip; the Community grid must
|
||||
// still come up unfiltered ("All"), not pre-snapped to that chip.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plugins-home-pill-category-deck')).toBeTruthy();
|
||||
});
|
||||
expect(ariaSelected('plugins-home-pill-category-all')).toBe('true');
|
||||
expect(ariaSelected('plugins-home-pill-category-prototype')).toBe('false');
|
||||
|
||||
// Picking another chip drives the composer, not the gallery filter.
|
||||
fireEvent.click(await screen.findByTestId('home-hero-rail-deck'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Slide deck');
|
||||
});
|
||||
expect(ariaSelected('plugins-home-pill-category-all')).toBe('true');
|
||||
expect(ariaSelected('plugins-home-pill-category-deck')).toBe('false');
|
||||
|
||||
// And the gallery's own pills still work locally.
|
||||
fireEvent.click(screen.getByTestId('plugins-home-pill-category-deck'));
|
||||
expect(ariaSelected('plugins-home-pill-category-deck')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,26 @@ const sample: InstalledPluginRecord[] = [
|
||||
makePlugin({ id: 'hidden-atom', mode: 'prototype', tags: ['dashboard'], kind: 'atom' }),
|
||||
];
|
||||
|
||||
describe('PluginsHomeSection (community gallery)', () => {
|
||||
it('keeps gallery tiles free of inline Use actions — Use lives in the detail modal', () => {
|
||||
renderSection(sample, { cardLayout: 'gallery' });
|
||||
|
||||
// The tile itself stays a pure preview: name button opens details,
|
||||
// ↗ opens the example page. Use / Use with query are detail-modal
|
||||
// affordances only.
|
||||
expect(screen.getByTestId('plugins-home-details-prototype-dashboard')).toBeTruthy();
|
||||
expect(screen.queryByTestId('plugins-home-use-prototype-dashboard')).toBeNull();
|
||||
expect(screen.queryByTestId('plugins-home-use-menu-prototype-dashboard')).toBeNull();
|
||||
expect(screen.queryByTestId('plugins-home-use-with-query-prototype-dashboard')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the inline Use menu on the rich management layout (PluginsView)', () => {
|
||||
renderSection(sample, { cardLayout: 'rich' });
|
||||
|
||||
expect(screen.getByTestId('plugins-home-use-prototype-dashboard')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsHomeSection (category bar)', () => {
|
||||
it('frames the home shelf as community and can jump to registry', () => {
|
||||
const onBrowseRegistry = vi.fn();
|
||||
|
||||
@@ -198,9 +198,15 @@ test('[P2] captures the home plugin use with query surface', async ({ page }) =>
|
||||
await home.getByTestId('plugins-home-pill-category-deck').click();
|
||||
const card = home.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]');
|
||||
await expect(card).toBeVisible();
|
||||
await card.hover();
|
||||
await home.getByTestId('plugins-home-use-menu-visual-deck-writer').click({ force: true });
|
||||
await home.getByTestId('plugins-home-use-with-query-visual-deck-writer').click();
|
||||
// Community gallery tiles carry no inline Use actions — use-with-query
|
||||
// lives behind the detail modal's split Use button.
|
||||
await home.getByTestId('plugins-home-details-visual-deck-writer').click({ force: true });
|
||||
// Deck Writer ships a previewEntry, so its detail surface is the
|
||||
// PreviewModal (aria-label "Deck Writer preview"), not the scenario
|
||||
// detail's "... details" dialog. Match on the plugin name only.
|
||||
await expect(page.getByRole('dialog', { name: /Deck Writer/i })).toBeVisible();
|
||||
await page.getByTestId('plugin-details-use-visual-deck-writer-menu').click();
|
||||
await page.getByTestId('plugin-details-use-with-query-visual-deck-writer').click();
|
||||
// use-with-query now seeds the rendered preset text (placeholders filled in),
|
||||
// not the raw `{{...}}` query — matching the example-prompt card path.
|
||||
await expect(page.getByTestId('home-hero-input')).toContainText('Draft a topic deck.');
|
||||
|
||||
Reference in New Issue
Block a user