feat(web): combine plugin settings into tabs

This commit is contained in:
ZiyaZhang
2026-08-12 00:38:10 -07:00
parent b1c13b5ca3
commit 4f5c730032
50 changed files with 512 additions and 217 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
README.md: 7e530d70f6573d619378e43b0245345b45d6db18
README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc
README.md: 1bf8dd60b966ee0c0e28ae931556182c5c2b2e87
README.zh.md: bd87e06202b2b083a6e9e089eb91681deea9d678

View File

@@ -2,17 +2,17 @@
English | [中文](README.zh.md)
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
## What appears here
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs.
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
## Extension point
The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`.
## Writes

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
**插件**设置分区每个配置由用户拥有的 Host 插件一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
## 这里会出现什么
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。
第一批覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
## 扩展点
本分区声明根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot `order`
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序遵循贡献`order`
## 写入

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"

View File

@@ -0,0 +1,25 @@
/** Configurable Host plugins contributed to the shared Plugins section. */
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import css from './PluginConfigSection.module.css'
/** Registration-side business face for the configurable tab. */
export interface ConfigurablePluginsTabInjected {
/** How many cards the slot ledger held when the tab registration mounted. */
cardCount: number
}
/** Props the renderer binds for the configurable tab. */
export type ConfigurablePluginsTabProps =
PropsRuntime<'settings.plugins.tab'>
& PropsLocale<'settings.pluginConfig'>
& PropsRenderSlots<'settings.plugin.item'>
& InjectFace<ConfigurablePluginsTabInjected>
/** Render cards registered by plugins that expose editable settings. */
export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) {
return cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>
}

View File

@@ -1,10 +1,10 @@
/* Plugin configuration section: heading, intro, and the card list. */
/* Plugins section: compact tabs plus the configurable plugin card list. */
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
max-width: 760px;
color: var(--dsw-alias-label-primary);
}
@@ -20,6 +20,53 @@
color: var(--dsw-alias-label-tertiary);
}
.tabs {
display: flex;
align-items: flex-end;
gap: 22px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
margin-top: 2px;
}
.tab {
position: relative;
border: 0;
padding: 7px 1px 9px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.tab:hover,
.tab[data-active='true'] {
color: var(--dsw-alias-label-primary);
}
.tab[data-active='true']::after,
.tab:focus-visible::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: var(--dsw-alias-label-primary);
content: '';
}
.tab:focus-visible {
outline: none;
color: var(--dsw-alias-label-primary);
}
.panel {
min-width: 0;
padding-top: 2px;
}
.cards {
list-style: none;
margin: 0;

View File

@@ -1,49 +1,105 @@
/**
* Plugin configuration section: the shell around the per-plugin cards. It
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
* slot it declares, so a plugin that ships a browser half owns its own card
* and this section never learns what a namespace means.
*/
/** Plugins settings section: localized tabs around feature-owned pages. */
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import { useEffect, useId, useState } from 'react'
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginConfigSection.module.css'
/** One tab projected from a `settings.plugins.tab` contribution. */
export interface PluginSettingsTabRow {
id: string
order: number
label: string
}
/** Registration-side business face for the section. */
export interface PluginConfigSectionInjected {
/** How many cards the slot ledger currently holds; zero renders the empty line. */
cardCount: number
hooks: {
/** Ordered, locale-aware projection of the Plugins tab ledger. */
tabs: HostObservable<readonly PluginSettingsTabRow[]>
}
}
/** Props the renderer binds for the section. */
export type PluginConfigSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.pluginConfig'>
& PropsRenderSlots<'settings.plugin.item'>
& PropsRenderSlots<'settings.plugins.tab'>
& InjectFace<PluginConfigSectionInjected>
/**
* Render the plugin configuration section.
* @param props - runtime slot rendering, locale copy, and the card count.
* @returns the section.
*/
export function PluginConfigSection(props: PluginConfigSectionProps) {
const { t, renderSlot, cardCount } = props
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) {
const tabsId = useId()
const rows = useTabs(value => value)
const [activeId, setActiveId] = useState<string>()
const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
// A tab mounts only when first selected, then stays mounted while hidden so
// local drafts, disclosure state, search, and the inventory snapshot survive
// switching between the two views.
useEffect(() => {
if (active === undefined) return
setVisitedIds((previous) => {
if (previous.has(active)) return previous
return new Set([...previous, active])
})
}, [active])
return (
<div className={css.section}>
<h2 className={css.heading}>{t('title')}</h2>
<p className={css.intro}>{t('intro')}</p>
{cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
<>
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
{rows.map((row) => {
const selected = row.id === active
return (
<button
key={row.id}
id={`${tabsId}-tab-${row.id}`}
type="button"
role="tab"
className={css.tab}
aria-selected={selected}
aria-controls={`${tabsId}-panel-${row.id}`}
data-active={selected ? 'true' : undefined}
onClick={() => { setActiveId(row.id) }}
>
{row.label}
</button>
)
})}
</div>
{rows
.filter(row => row.id === active || visitedIds.has(row.id))
.map((row) => {
const selected = row.id === active
return (
<div
key={row.id}
id={`${tabsId}-panel-${row.id}`}
className={css.panel}
role="tabpanel"
aria-labelledby={`${tabsId}-tab-${row.id}`}
hidden={!selected}
>
{renderSlot('settings.plugins.tab', {}, { only: row.id })}
</div>
)
})}
</>
)}
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Plugin configuration section and card copy. */
/** Plugins section, configurable-tab, and card copy. */
'settings.pluginConfig': PluginConfigKey
}
}

View File

@@ -1,13 +1,12 @@
/**
* Plugin configuration surface, browser half — one settings section holding
* an expandable card per Host plugin whose configuration a user owns.
* Plugins settings surface, browser half — one section whose feature-owned
* tabs include configurable Host plugin cards and read-only inventory.
*
* The section owns no knowledge of any namespace: it declares the
* `settings.plugin.item` slot and renders whatever cards were registered into
* it, so a plugin that ships a browser half contributes its own card and its
* own controls. The three cards this package registers are the host-plane
* sections the deployment already exposes; each binds its namespace through
* the client settings scope, which keeps them unaware of one another.
* The section declares `settings.plugins.tab`; its own `configurable` tab then
* declares `settings.plugin.item` and renders whatever cards were registered
* into it. The three cards this package ships are the host-plane sections the
* deployment already exposes; each binds its namespace through the client
* settings scope, which keeps them unaware of one another and of other tabs.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -18,11 +17,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
// through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { AgentLoopCard } from './AgentLoopCard.tsx'
import { BashCard } from './BashCard.tsx'
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
import { PluginConfigSection } from './PluginConfigSection.tsx'
import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
import { BASH_NS, BashCardController } from './bash-store.ts'
@@ -30,6 +33,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
import { en, zh } from './locales.ts'
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
export type { PluginCardProps } from './PluginCard.tsx'
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
export type { FieldProps } from './fields.tsx'
@@ -67,23 +71,67 @@ export function apply(ctx: ClientContext): void {
'ui-plugin-config: credential invalidations',
)
// The section renders the empty line rather than an empty list when no plugin
// contributed a card. The count is read once: the renderer caches a root
// entry's inject face per registration, so this reports what was registered
// when the section mounted, not what is visible now. Both gaps are bounded by
// this deployment always registering the three cards below — a card that
// arrives later would not raise the count, and a namespace this deployment
// does not expose leaves its card rendering nothing inside a non-empty list.
let tabsVersion = -1
let tabsRevision = -1
let tabs: readonly PluginSettingsTabRow[] = []
const sectionInjected = (): PluginConfigSectionInjected => ({
hooks: {
tabs: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.plugins.tab')
const revision = ctx.locale.getSnapshot().revision
if (version !== tabsVersion || revision !== tabsRevision) {
tabsVersion = version
tabsRevision = revision
tabs = ctx.slots.entries('settings.plugins.tab')
.map(entry => ({
/* v8 ignore next -- list-slot registration requires id */
id: entry.options.id ?? '',
order: entry.options.order ?? 0,
label: resolveSlotLabel(entry.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return tabs
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener)
const offLocale = ctx.locale.subscribe(listener)
return () => {
offLedger()
offLocale()
}
},
},
},
})
// This package owns the one Plugins navigation entry and the tab chrome;
// feature plugins contribute pages without competing for Settings nav rows.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugins',
order: 30,
order: 15,
label: () => t('nav'),
locale: NS,
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
inject: sectionInjected,
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
}, PluginConfigSection))
// The existing configuration page is one ordinary tab. It keeps ownership
// of the card slot and the three shipped card contributions below.
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'configurable',
order: 0,
label: () => t('configurableTab'),
locale: NS,
inject: (): ConfigurablePluginsTabInjected => ({
cardCount: ctx.slots.entries('settings.plugin.item').length,
}),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
}, ConfigurablePluginsTab))
ctx.slots.inject('settings.plugin.item', function* () {
yield ctx.slots.register({
name: 'settings.plugin.item',

View File

@@ -2,7 +2,7 @@
/** Locale keys these surfaces render. */
export type PluginConfigKey =
| 'nav' | 'title' | 'intro' | 'empty'
| 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
@@ -14,9 +14,11 @@ export type PluginConfigKey =
/** English copy. */
export const en: Record<PluginConfigKey, string> = {
nav: 'Plugin config',
title: 'Plugin configuration',
intro: 'Configure the plugins this deployment installed.',
nav: 'Plugins',
title: 'Plugins',
intro: 'Configure and inspect the plugins installed in this deployment.',
tabs: 'Plugin views',
configurableTab: 'Plugin configuration',
empty: 'This deployment exposes no plugin settings.',
overridden: 'Overridden',
reset: 'Reset to default',
@@ -53,9 +55,11 @@ export const en: Record<PluginConfigKey, string> = {
/** Simplified Chinese copy. */
export const zh: Record<PluginConfigKey, string> = {
nav: '插件配置',
title: '插件配置',
intro: '配置本部署已安装的插件。',
nav: '插件',
title: '插件',
intro: '配置和查看本部署已安装的插件。',
tabs: '插件视图',
configurableTab: '插件配置',
empty: '本部署没有开放任何插件设置。',
overridden: '已覆盖',
reset: '恢复默认',

View File

@@ -1,7 +1,7 @@
/**
* Plugin configuration surface, node half. The empty apply exists so the
* plugin appears in the host cordis.yml / Loader; the browser half ships the
* settings section through exports["./client"], discovered from the
* Plugins settings surface, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half owns the section
* and its configurable tab through exports["./client"], discovered from the
* package.json dsh.client declaration. Every section this page edits is owned
* by the Host plugin that registered it, so this package registers no
* namespace of its own.

View File

@@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
import type {
ConfigurablePluginsTabInjected, PluginConfigSectionInjected,
} from '@deepseek-ai/dsh-client-ui-plugin-config/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
@@ -46,16 +49,20 @@ describe('ui-plugin-config apply', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
})
it('registers the section and declares the per-plugin card slot', async () => {
it('registers one Plugins section and declares the tab and card slots', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(resolveSlotLabel(section.options.label)).toBe('插件')
expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
const tab = slots.entries('settings.plugins.tab')[0]!
expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
})
@@ -69,13 +76,18 @@ describe('ui-plugin-config apply', () => {
.toEqual(['bash', 'agent-loop', 'web-search'])
})
it('injects a live card count and one business face per card', async () => {
it('injects a live tab projection, a card count, and one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)()
expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
{ id: 'configurable', order: 0, label: '插件配置' },
])
const tab = slots.entries('settings.plugins.tab')[0]!
expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 })
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.
@@ -129,6 +141,7 @@ describe('ui-plugin-config apply', () => {
await fiber.dispose()
expect(slots.entries('settings.section')).toHaveLength(0)
expect(slots.spec('settings.plugins.tab')).toBeUndefined()
expect(slots.spec('settings.plugin.item')).toBeUndefined()
})
})

View File

@@ -13,8 +13,10 @@ import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
import { BashCard } from '../src/client/BashCard.tsx'
import type { BashCardProps } from '../src/client/BashCard.tsx'
import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx'
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
@@ -46,13 +48,24 @@ function cardActions() {
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
}
function renderSection(cardCount: number, cards = 'cards') {
function renderSection(rows: readonly PluginSettingsTabRow[]) {
const props = {
t,
useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows),
renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
<span>{options.only}</span>
),
} as unknown as PluginConfigSectionProps
render(<PluginConfigSection {...props} />)
}
function renderConfigurable(cardCount: number, cards = 'cards') {
const props = {
t,
cardCount,
renderSlot: () => <li>{cards}</li>,
} as unknown as PluginConfigSectionProps
render(<PluginConfigSection {...props} />)
} as unknown as ConfigurablePluginsTabProps
render(<ConfigurablePluginsTab {...props} />)
}
function renderBash(state: Partial<BashCardState> = {}) {
@@ -69,26 +82,53 @@ function renderBash(state: Partial<BashCardState> = {}) {
}
describe('PluginConfigSection', () => {
it('says so when no plugin contributed a tab', () => {
renderSection([])
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByRole('tab')).toBeNull()
})
it('defaults to the first ordered tab and mounts another only after selection', () => {
renderSection([
{ id: 'configurable', order: 0, label: en.configurableTab },
{ id: 'all', order: 10, label: 'Plugin list' },
])
const configurable = screen.getByRole('tab', { name: en.configurableTab })
const all = screen.getByRole('tab', { name: 'Plugin list' })
expect(configurable.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('configurable')).toBeTruthy()
expect(screen.queryByText('all')).toBeNull()
fireEvent.click(all)
expect(all.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('all')).toBeTruthy()
expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
})
it('leads with its own heading and intro', () => {
renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()
})
})
describe('ConfigurablePluginsTab', () => {
it('says so when no plugin contributed a card', () => {
renderSection(0)
renderConfigurable(0)
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByText('cards')).toBeNull()
})
it('renders the card list once a plugin contributed one', () => {
renderSection(1)
renderConfigurable(1)
expect(screen.getByText('cards')).toBeTruthy()
expect(screen.queryByText(en.empty)).toBeNull()
})
it('leads with its own heading and intro', () => {
renderSection(1)
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()
})
})
describe('BashCard', () => {