diff --git a/packages/components/src/extension-manager/components/ExtensionFilterControls.vue b/packages/components/src/extension-manager/components/ExtensionFilterControls.vue new file mode 100644 index 000000000..3b0bfa0db --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionFilterControls.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts b/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts new file mode 100644 index 000000000..57b950f26 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts @@ -0,0 +1,8 @@ +import type { ExtensionCardGridItem } from '../index.type' +import type { ExtensionManagerSectionKey } from '../index.type' + +export interface ExtensionManagerSectionState { + key: ExtensionManagerSectionKey + title: string + items: ExtensionCardGridItem[] +} diff --git a/packages/components/src/extension-manager/components/ExtensionManagerSection.vue b/packages/components/src/extension-manager/components/ExtensionManagerSection.vue new file mode 100644 index 000000000..99836061f --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerSection.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue b/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue new file mode 100644 index 000000000..e113dc15f --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts b/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts new file mode 100644 index 000000000..2fe5c4224 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts @@ -0,0 +1,31 @@ +import { reactive, toValue, watch, type MaybeRefOrGetter } from 'vue' +import type { ExtensionManagerTab } from '../index.type' +import type { FilterState } from '../filter.type' + +type TabStateRecord = Record + +const createSafeRecord = () => Object.create(null) as T +const createFilterState = (): FilterState => reactive({ selectedTag: '', searchValue: '' }) + +export const useExtensionManagerFilterState = (tabs: MaybeRefOrGetter[]>) => { + const states = reactive(createSafeRecord()) + const noTabState = createFilterState() + + watch( + () => toValue(tabs).map((tab) => tab.id), + (tabIds) => { + const allowed = new Set(tabIds) + for (const tabId of Object.keys(states)) { + if (!allowed.has(tabId)) delete states[tabId] + } + }, + { immediate: true }, + ) + + const getFilterState = (tabId: string | undefined): FilterState => { + if (tabId === undefined) return noTabState + return states[tabId] ?? (states[tabId] = createFilterState()) + } + + return { getFilterState } +} diff --git a/packages/components/src/extension-manager/composables/useExtensionManagerState.ts b/packages/components/src/extension-manager/composables/useExtensionManagerState.ts new file mode 100644 index 000000000..4b874be03 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionManagerState.ts @@ -0,0 +1,179 @@ +import { computed, ref, watch, type ComputedRef } from 'vue' +import type { + ExtensionManagerEmits, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerTab, +} from '../index.type' + +type SectionExpansionState = Record>> + +type ExtensionManagerState = { + activeTabId: ComputedRef + activeTab: ComputedRef + selectTab: (tabId: string) => void + isSectionExpanded: (tabId: string, sectionKey: ExtensionManagerSectionKey) => boolean + toggleSection: (tabId: string, sectionKey: ExtensionManagerSectionKey) => void +} + +const hasOwn = (record: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(record, key) + +const createSafeRecord = () => Object.create(null) as T + +const setRecordValue = (record: T, key: K, value: V) => { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +const cloneSectionExpansionState = (record: SectionExpansionState): SectionExpansionState => { + const clone = createSafeRecord() + + for (const [tabId, sectionRecord] of Object.entries(record)) { + const sectionClone = createSafeRecord>>() + + for (const [sectionKey, expanded] of Object.entries(sectionRecord ?? {})) { + if (sectionKey === 'installed' || sectionKey === 'available') { + setRecordValue(sectionClone, sectionKey, expanded) + } + } + + setRecordValue(clone, tabId, sectionClone) + } + + return clone +} + +const getSectionOverride = (record: SectionExpansionState, tabId: string, sectionKey: ExtensionManagerSectionKey) => { + if (!hasOwn(record, tabId)) return undefined + + const sectionRecord = record[tabId] + if (sectionRecord === undefined || !hasOwn(sectionRecord, sectionKey)) return undefined + + return sectionRecord[sectionKey] +} + +const setSectionOverride = ( + record: SectionExpansionState, + tabId: string, + sectionKey: ExtensionManagerSectionKey, + expanded: boolean, +) => { + const sectionRecord = + hasOwn(record, tabId) && record[tabId] !== undefined + ? record[tabId]! + : createSafeRecord>>() + + if (!hasOwn(record, tabId)) setRecordValue(record, tabId, sectionRecord) + setRecordValue(sectionRecord, sectionKey, expanded) +} + +const getExpanded = (record: SectionExpansionState, tabId: string, sectionKey: ExtensionManagerSectionKey) => + getSectionOverride(record, tabId, sectionKey) ?? true + +export const useExtensionManagerState = ( + props: Readonly, + emit: ExtensionManagerEmits, +): ExtensionManagerState => { + const uncontrolledActiveTabId = ref(props.defaultActiveTab) + const sectionExpansionState = ref(createSafeRecord()) + + const tabIds = computed(() => props.tabs.map((tab) => tab.id)) + const firstTabId = computed(() => tabIds.value[0]) + + watch( + tabIds, + (tabIds) => { + const allowedTabIds = new Set(tabIds) + const next = createSafeRecord() + + for (const [tabId, sectionRecord] of Object.entries(sectionExpansionState.value)) { + if (allowedTabIds.has(tabId)) setRecordValue(next, tabId, sectionRecord) + } + + sectionExpansionState.value = next + }, + { immediate: true }, + ) + + const normalizedUncontrolledActiveTab = (nextTabIds: string[]) => { + if (nextTabIds.includes(uncontrolledActiveTabId.value ?? '')) return + + uncontrolledActiveTabId.value = nextTabIds[0] + } + + watch( + [tabIds, () => props.activeTab], + ([nextTabIds, activeTab]) => { + if (activeTab !== undefined) return + + normalizedUncontrolledActiveTab(nextTabIds) + }, + { immediate: true }, + ) + + const activeTabId = computed(() => { + const requestedTabId = props.activeTab ?? uncontrolledActiveTabId.value + const requestedTab = props.tabs.find((tab) => tab.id === requestedTabId) + + return requestedTab?.id ?? firstTabId.value + }) + + const activeTab = computed(() => + activeTabId.value === undefined ? undefined : props.tabs.find((tab) => tab.id === activeTabId.value), + ) + + let lastEmittedActiveTabId = props.activeTab + watch( + activeTabId, + (nextActiveTabId) => { + if (props.activeTab !== undefined && nextActiveTabId === props.activeTab) { + lastEmittedActiveTabId = nextActiveTabId + return + } + + if (nextActiveTabId === lastEmittedActiveTabId) return + + lastEmittedActiveTabId = nextActiveTabId + emit('update:active-tab', nextActiveTabId) + }, + { immediate: true }, + ) + + const isSectionExpanded = (tabId: string, sectionKey: ExtensionManagerSectionKey) => { + return getExpanded(sectionExpansionState.value, tabId, sectionKey) + } + + const selectTab = (tabId: string) => { + const tab = props.tabs.find((candidate) => candidate.id === tabId) + if (!tab || activeTabId.value === tab.id) return + + if (props.activeTab === undefined) uncontrolledActiveTabId.value = tab.id + else emit('update:active-tab', tab.id) + + emit('tab-change', { tabId: tab.id }) + } + + const toggleSection = (tabId: string, sectionKey: ExtensionManagerSectionKey) => { + if (!props.tabs.some((tab) => tab.id === tabId)) return + + const expanded = !isSectionExpanded(tabId, sectionKey) + + const nextSectionExpansionState = cloneSectionExpansionState(sectionExpansionState.value) + setSectionOverride(nextSectionExpansionState, tabId, sectionKey, expanded) + sectionExpansionState.value = nextSectionExpansionState + + emit('section-toggle', { tabId, sectionKey, expanded }) + } + + return { + activeTabId, + activeTab, + selectTab, + isSectionExpanded, + toggleSection, + } +} diff --git a/packages/components/src/extension-manager/composables/useFilter.ts b/packages/components/src/extension-manager/composables/useFilter.ts new file mode 100644 index 000000000..e379e052b --- /dev/null +++ b/packages/components/src/extension-manager/composables/useFilter.ts @@ -0,0 +1,85 @@ +import { computed, reactive, toValue, watch } from 'vue' +import type { FilterState, UseFilterOptions, UseFilterResult } from '../filter.type' + +const createFilterState = (): FilterState => reactive({ selectedTag: '', searchValue: '' }) + +const normalizeTags = (tags: readonly T[]) => { + const seen = new Set() + const normalized: T[] = [] + + for (const tag of tags) { + if (!tag.value || seen.has(tag.value)) continue + seen.add(tag.value) + normalized.push(tag) + } + + return normalized +} + +const defaultGetItemTags = (item: T): readonly string[] => { + const tags = (item as { tags?: unknown }).tags + return Array.isArray(tags) && tags.every((tag): tag is string => typeof tag === 'string') ? tags : [] +} + +const defaultSearch = (item: T, query: string) => { + const searchable = item as { name?: unknown; description?: unknown } + const name = typeof searchable.name === 'string' ? searchable.name : '' + const description = typeof searchable.description === 'string' ? searchable.description : '' + return `${name} ${description}`.toLowerCase().includes(query) +} + +export const useFilter = (options: UseFilterOptions): UseFilterResult => { + const internalState = createFilterState() + const currentState = computed(() => toValue(options.state) ?? internalState) + + const selectedTag = computed({ + get: () => currentState.value.selectedTag, + set: (value: string) => { + currentState.value.selectedTag = value + }, + }) + const searchValue = computed({ + get: () => currentState.value.searchValue, + set: (value: string) => { + currentState.value.searchValue = value + }, + }) + + const normalizedTags = computed(() => normalizeTags(toValue(options.tags))) + + watch( + normalizedTags, + (tags) => { + if (selectedTag.value && !tags.some((tag) => tag.value === selectedTag.value)) { + selectedTag.value = '' + } + }, + { immediate: true }, + ) + + const filteredItems = computed(() => { + const query = searchValue.value.trim().toLowerCase() + const getItemTags = options.rules?.getItemTags ?? defaultGetItemTags + const search = options.rules?.search ?? defaultSearch + + return toValue(options.items).filter((item) => { + const matchesTag = !selectedTag.value || getItemTags(item).includes(selectedTag.value) + const matchesSearch = !query || search(item, query) + return matchesTag && matchesSearch + }) + }) + + const controls = computed(() => ({ + tags: normalizedTags.value, + selectedTag: selectedTag.value, + searchValue: searchValue.value, + 'onUpdate:selectedTag': (value: string) => { + selectedTag.value = value + }, + 'onUpdate:searchValue': (value: string) => { + searchValue.value = value + }, + })) + + return { filteredItems, selectedTag, searchValue, controls } +} diff --git a/packages/components/src/extension-manager/filter.type.ts b/packages/components/src/extension-manager/filter.type.ts new file mode 100644 index 000000000..f56b3a168 --- /dev/null +++ b/packages/components/src/extension-manager/filter.type.ts @@ -0,0 +1,34 @@ +import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue' +import type { ExtensionManagerTagOption } from './index.type' + +export interface FilterState { + selectedTag: string + searchValue: string +} + +export interface FilterRules { + getItemTags?: (item: T) => readonly string[] + search?: (item: T, query: string) => boolean +} + +export interface UseFilterOptions { + items: MaybeRefOrGetter + tags: MaybeRefOrGetter + rules?: FilterRules + state?: MaybeRefOrGetter +} + +export interface FilterControls { + tags: readonly ExtensionManagerTagOption[] + selectedTag: string + searchValue: string + 'onUpdate:selectedTag': (value: string) => void + 'onUpdate:searchValue': (value: string) => void +} + +export interface UseFilterResult { + filteredItems: ComputedRef + selectedTag: WritableComputedRef + searchValue: WritableComputedRef + controls: ComputedRef +} diff --git a/packages/components/src/extension-manager/index.ts b/packages/components/src/extension-manager/index.ts index f502a2aa1..ce38b1112 100644 --- a/packages/components/src/extension-manager/index.ts +++ b/packages/components/src/extension-manager/index.ts @@ -1,4 +1,5 @@ import type { App } from 'vue' +import ExtensionManagerComp from './index.vue' import { ExtensionCard as ExtensionCardComp, ExtensionCardGrid as ExtensionCardGridComp } from './components' export * from './index.type' @@ -27,4 +28,17 @@ const ExtensionCardGrid = ExtensionCardGridComp as typeof ExtensionCardGridComp ExtensionCardGrid.install = extensionCardGridInstall -export { ExtensionCard, ExtensionCardGrid } +ExtensionManagerComp.name = 'TrExtensionManager' + +const extensionManagerInstall = function (app: App) { + app.component(ExtensionManagerComp.name!, ExtensionManagerComp) +} + +const ExtensionManager = Object.assign(ExtensionManagerComp, { + install: extensionManagerInstall, + Card: ExtensionCard, + CardGrid: ExtensionCardGrid, +}) + +export { ExtensionCard, ExtensionCardGrid, ExtensionManager } +export default ExtensionManager diff --git a/packages/components/src/extension-manager/index.type.test.ts b/packages/components/src/extension-manager/index.type.test.ts new file mode 100644 index 000000000..d401d2de2 --- /dev/null +++ b/packages/components/src/extension-manager/index.type.test.ts @@ -0,0 +1,230 @@ +import type { Component } from 'vue' +import type { + ExtensionCardGridActionEvent, + ExtensionCardGridEmits, + ExtensionCardGridItem, + ExtensionCardGridNameClickEvent, + ExtensionCardGridProps, + ExtensionCardGridSlots, + ExtensionCardAction, + ExtensionCardActionEvent, + ExtensionCardProps, + ExtensionManagerActionEvent, + ExtensionManagerEmits, + ExtensionManagerItem, + ExtensionManagerNameClickEvent, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerSectionToggleEvent, + ExtensionManagerSlots, + ExtensionManagerTab, + ExtensionManagerTabChangeEvent, +} from './index.type' + +const icon = {} as Component + +const cardProps: ExtensionCardProps = { + name: 'Standalone card', + description: 'Card description', + icon, + actions: [ + { id: 'enabled', type: 'switch', label: '启用扩展', checked: true }, + { id: 'install', type: 'button', label: '安装' }, + { id: 'inspect', type: 'custom', label: '检查', data: { origin: 'type-test' } }, + ], + primaryActionsLimit: 1, + progress: 'indeterminate', + nameClickable: false, + overflowMenuLabel: '扩展操作', + overflowMenuPlacement: 'top-end', +} + +const switchAction: ExtensionCardAction = { + id: 'enabled', + type: 'switch', + label: '启用扩展', + checked: false, +} +const buttonAction: ExtensionCardAction = { + id: 'install', + type: 'button', + label: '安装', +} +const customAction: ExtensionCardAction = { + id: 'inspect', + type: 'custom', + label: '检查', +} +const event: ExtensionCardActionEvent = { + id: 'enabled', + type: 'switch', + checked: true, +} + +const cardGridItem: ExtensionCardGridItem = { + ...cardProps, + id: 'grid-item', +} + +const cardGridProps: ExtensionCardGridProps = { + items: [cardGridItem], + emptyText: 'No cards', + primaryActionsLimit: 1, + nameClickable: true, + overflowMenuLabel: 'Grid actions', + overflowMenuPlacement: 'top-end', + overflowMenuShowIcons: false, +} +const cardGridDefaultProps: ExtensionCardGridProps = { + items: [cardGridItem], +} + +const cardGridSlots: ExtensionCardGridSlots = { + item: ({ item, index }) => { + const itemId: string = item.id + const itemName: string = item.name + const itemIndex: number = index + + void itemId + void itemName + void itemIndex + return [] + }, + empty: () => [], +} + +const cardGridActionEvent: ExtensionCardGridActionEvent = { + itemId: cardGridItem.id, + action: event, +} +const cardGridNameClickEvent: ExtensionCardGridNameClickEvent = { + itemId: cardGridItem.id, + event: {} as MouseEvent, +} + +declare const cardGridEmit: ExtensionCardGridEmits +cardGridEmit('action', cardGridActionEvent) +cardGridEmit('name-click', cardGridNameClickEvent) + +const managerItem: ExtensionManagerItem = { + ...cardProps, + id: 'manager-item', + installed: true, +} + +const managerTab: ExtensionManagerTab = { + id: 'catalog', + label: 'Catalog', + items: [managerItem], +} +const secondManagerTab: ExtensionManagerTab = { + id: 'updates', + label: 'Updates', + items: [], +} + +const managerProps: ExtensionManagerProps = { + tabs: [managerTab, secondManagerTab], + activeTab: 'catalog', + defaultActiveTab: 'updates', + title: 'Extensions', + emptyText: 'No enabled tabs', +} + +const managerTabChangeEvent: ExtensionManagerTabChangeEvent = { + tabId: managerTab.id, +} +const managerSectionKey: ExtensionManagerSectionKey = 'installed' +const managerSectionToggleEvent: ExtensionManagerSectionToggleEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + expanded: true, +} +const managerActionEvent: ExtensionManagerActionEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + itemId: managerItem.id, + action: event, +} +const managerNameClickEvent: ExtensionManagerNameClickEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + itemId: managerItem.id, + event: {} as MouseEvent, +} + +const managerSlots: ExtensionManagerSlots = { + 'header-actions': () => [], + tab: ({ tab, active, select }) => { + const tabId: string = tab.id + const isActive: boolean = active + select() + + void tabId + void isActive + return [] + }, + item: ({ tab, sectionKey, item, index }) => { + const tabId: string = tab.id + const itemId: string = item.id + const itemIndex: number = index + + void tabId + void sectionKey + void itemId + void itemIndex + return [] + }, + empty: ({ tab, sectionKey, title }) => { + const tabId: string = tab.id + const sectionTitle: string = title + + void tabId + void sectionKey + void sectionTitle + return [] + }, +} + +declare const managerEmit: ExtensionManagerEmits +managerEmit('update:active-tab', managerTab.id) +managerEmit('update:active-tab', undefined) +managerEmit('tab-change', managerTabChangeEvent) +managerEmit('section-toggle', managerSectionToggleEvent) +managerEmit('action', managerActionEvent) +managerEmit('name-click', managerNameClickEvent) + +// @ts-expect-error Manager items retain Grid-owned identity. +const managerItemWithoutId: ExtensionManagerItem = { name: 'Missing Manager identity' } +// @ts-expect-error ExtensionCardGridItem requires the Grid-owned id. +const cardGridItemWithoutId: ExtensionCardGridItem = { name: 'Missing Grid identity' } +// @ts-expect-error Every action requires a label. +const unlabeledAction: ExtensionCardAction = { id: 'install', type: 'button' } +// @ts-expect-error Domain-specific install action is not a Card action type. +const installAction: ExtensionCardAction = { id: 'install', type: 'install', label: '安装' } + +void cardProps +void switchAction +void buttonAction +void customAction +void event +void cardGridItem +void cardGridProps +void cardGridDefaultProps +void cardGridSlots +void cardGridActionEvent +void cardGridNameClickEvent +void managerItem +void managerTab +void secondManagerTab +void managerProps +void managerTabChangeEvent +void managerSectionToggleEvent +void managerActionEvent +void managerNameClickEvent +void managerSectionKey +void managerSlots +void managerItemWithoutId +void cardGridItemWithoutId +void unlabeledAction +void installAction diff --git a/packages/components/src/extension-manager/index.vue b/packages/components/src/extension-manager/index.vue new file mode 100644 index 000000000..e2132b0d1 --- /dev/null +++ b/packages/components/src/extension-manager/index.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/packages/components/src/extension-manager/public.type.ts b/packages/components/src/extension-manager/public.type.ts new file mode 100644 index 000000000..bf95edb30 --- /dev/null +++ b/packages/components/src/extension-manager/public.type.ts @@ -0,0 +1,30 @@ +export type { + ExtensionCardAction, + ExtensionCardActionBase, + ExtensionCardActionEvent, + ExtensionCardButtonAction, + ExtensionCardCustomAction, + ExtensionCardEmits, + ExtensionCardGridActionEvent, + ExtensionCardGridEmits, + ExtensionCardGridItem, + ExtensionCardGridNameClickEvent, + ExtensionCardGridProps, + ExtensionCardGridSlots, + ExtensionCardOverflowMenuPlacement, + ExtensionCardProps, + ExtensionCardRenderableAction, + ExtensionCardSlots, + ExtensionCardSwitchAction, + ExtensionManagerActionEvent, + ExtensionManagerEmits, + ExtensionManagerItem, + ExtensionManagerNameClickEvent, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerSectionToggleEvent, + ExtensionManagerSlots, + ExtensionManagerTagOption, + ExtensionManagerTab, + ExtensionManagerTabChangeEvent, +} from './index.type' diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index e7da473b0..d7c510c8b 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,7 +1,7 @@ import './styles/root.css' import './styles/components/index.css' -import { App } from 'vue' +import type { App } from 'vue' import Attachments from './attachments' import { Bubble, BubbleList, BubbleProvider } from './bubble' import Container from './container' @@ -23,7 +23,7 @@ import ThemeProvider from './theme-provider' import Welcome from './welcome' import McpServerPicker from './mcp-server-picker' import McpAddForm from './mcp-add-form' -import { ExtensionCard, ExtensionCardGrid } from './extension-manager' +import ExtensionManager, { ExtensionCard, ExtensionCardGrid } from './extension-manager' import { ActionButton, SubmitButton, @@ -76,7 +76,6 @@ export { useTheme } from './theme-provider/useTheme' export { useSenderContext } from './sender' export { vDropzone } from './drag-overlay/directives/vDropzone' export { useAutoScroll, useTouchDevice } from './shared/composables' - const components = [ Attachments, Bubble, @@ -105,6 +104,7 @@ const components = [ Welcome, McpServerPicker, McpAddForm, + ExtensionManager, ExtensionCard, ExtensionCardGrid, ActionButton, @@ -180,6 +180,8 @@ export { McpServerPicker as TrMcpServerPicker, McpAddForm, McpAddForm as TrMcpAddForm, + ExtensionManager, + ExtensionManager as TrExtensionManager, ExtensionCard, ExtensionCard as TrExtensionCard, ExtensionCardGrid, diff --git a/packages/test/component/extension-manager/ExtensionManager.fixture.vue b/packages/test/component/extension-manager/ExtensionManager.fixture.vue new file mode 100644 index 000000000..4a8b59553 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManager.fixture.vue @@ -0,0 +1,178 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionManager.spec.ts b/packages/test/component/extension-manager/ExtensionManager.spec.ts new file mode 100644 index 000000000..a8e110869 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManager.spec.ts @@ -0,0 +1,362 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionManagerFixture from './ExtensionManager.fixture.vue' +import ExtensionManagerUncontrolledFixture from './ExtensionManagerUncontrolled.fixture.vue' + +test.describe('ExtensionManager section model', () => { + test('derives stable installed and available sections from the active tab items', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.locator('[data-section-key="installed"]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"]')).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveCount(1) + + const installedIds = await manager + .locator('[data-section-key="installed"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + const availableIds = await manager + .locator('[data-section-key="available"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + + expect(installedIds).toEqual(['alpha']) + expect(availableIds).toEqual(['beta', 'gamma']) + }) + + test('keeps both sections visible and renders the empty slot for an empty derived section', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await component.getByTestId('set-active-market').click() + + const manager = component.getByTestId('manager-host') + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveCount(1) + await expect(manager.getByTestId('empty-slot-market-available')).toHaveText('Empty available') + }) + + test('defaults derived Cards to one primary action', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.getByRole('button', { name: 'Install Beta' })).toHaveCount(1) + await expect(manager.locator('[data-card-id="beta"] [aria-label="更多操作"]')).toHaveCount(1) + }) + + test('moves an item between derived sections when installed changes', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await component.getByTestId('install-beta').click() + + await expect(manager.locator('[data-section-key="installed"] li[data-card-id="beta"]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"] li[data-card-id="beta"]')).toHaveCount(0) + }) + + test('uses default section headers and empty slot context', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveText('已安装') + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveText('可安装') + + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('empty-slot-context-market-available')).toHaveText('market/available/可安装') + }) + + test('keeps Manager-only metadata out of the Card/Grid item boundary', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await component.getByTestId('show-item-slot-manager').click() + + const itemSlotKeys = component.getByTestId('item-slot-manager').getByTestId('item-slot-keys') + await expect(itemSlotKeys).toHaveCount(3) + for (const keys of await itemSlotKeys.allTextContents()) { + expect(JSON.parse(keys)).not.toContain('installed') + expect(JSON.parse(keys)).not.toContain('tags') + } + }) + + test('preserves collapse state per tab and section key', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const libraryInstalled = manager + .locator('section[data-tab-id="library"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + const libraryAvailable = manager + .locator('section[data-tab-id="library"][data-section-key="available"]') + .getByRole('button', { name: '可安装', exact: true }) + + await libraryInstalled.click() + await expect(libraryInstalled).toHaveAttribute('aria-expanded', 'false') + await expect(libraryAvailable).toHaveAttribute('aria-expanded', 'true') + + await component.getByTestId('set-active-market').click() + const marketInstalled = manager + .locator('section[data-tab-id="market"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + await expect(marketInstalled).toHaveAttribute('aria-expanded', 'true') + + await marketInstalled.click() + await component.getByTestId('set-active-library').click() + await expect(libraryInstalled).toHaveAttribute('aria-expanded', 'false') + await expect(manager.locator('section[data-tab-id="market"]')).toHaveCount(0) + }) + + test('routes Card action and name-click events with section keys', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const item = component.getByTestId('manager-host').locator('li[data-card-id="alpha"]') + + await item.locator('.tr-extension-card-primary-actions__switch-track').click() + await item.getByRole('button', { name: 'Alpha extension', exact: true }).click() + + await expect(component.getByTestId('event-log')).toContainText( + 'action:{"tabId":"library","sectionKey":"installed","itemId":"alpha","action":{"id":"toggle-alpha","type":"switch","checked":false}}', + ) + await expect(component.getByTestId('event-log')).toContainText( + 'name-click:{"tabId":"library","sectionKey":"installed","itemId":"alpha","event":{"type":"click"}}', + ) + }) +}) + +test.describe('ExtensionManager uncontrolled state', () => { + test('activates and focuses tabs with Arrow, Home, and End keys', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + await dashTab.focus() + await dashTab.press('ArrowRight') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(slashTab).toHaveAttribute('tabindex', '0') + await expect(dashTab).toHaveAttribute('tabindex', '-1') + await expect(slashTab).toBeFocused() + + await slashTab.press('ArrowLeft') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toBeFocused() + + await dashTab.press('Home') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(slashTab).toBeFocused() + + await slashTab.press('End') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toBeFocused() + }) + + test('moves the shared indicator to the active tab', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const indicator = manager.locator('.extension-manager-tabs__indicator') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + const expectIndicatorAt = async (tab: typeof slashTab) => { + await expect + .poll(async () => { + const target = await tab.evaluate((element) => ({ + left: (element as HTMLElement).offsetLeft, + width: (element as HTMLElement).offsetWidth, + })) + const indicatorPosition = await indicator.evaluate((element) => ({ + transform: (element as HTMLElement).style.transform, + width: (element as HTMLElement).style.width, + })) + + return ( + indicatorPosition.transform === `translateX(${target.left}px)` && + indicatorPosition.width === `${target.width}px` + ) + }) + .toBe(true) + } + + await expectIndicatorAt(dashTab) + await slashTab.click() + await expectIndicatorAt(slashTab) + }) + + test('keeps Enter and Space activation through native tab buttons', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + await slashTab.focus() + await slashTab.press('Enter') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + + await dashTab.focus() + await dashTab.press('Space') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + }) + + test('uses internal active and section expansion state without v-model bindings', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashSection = manager + .locator('section[data-tab-id="a-b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashSection).toHaveAttribute('aria-expanded', 'true') + + await dashSection.click() + + await expect(dashSection).toHaveAttribute('aria-expanded', 'false') + await expect(component.getByTestId('uncontrolled-event-log')).toContainText('section-toggle:a-b/installed/false') + + await slashTab.click() + + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toHaveAttribute('aria-selected', 'false') + }) + + test('keeps arbitrary tab ids safe in the nested expansion model', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('identity-manager') + const slashSection = manager + .locator('section[data-tab-id="a/b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + + await expect(slashSection).toHaveAttribute('aria-expanded', 'true') + await slashSection.click() + await expect(slashSection).toHaveAttribute('aria-expanded', 'false') + + await manager.getByRole('tab', { name: /Dash identity tab/ }).click() + + await expect( + manager + .locator('section[data-tab-id="a-b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }), + ).toHaveAttribute('aria-expanded', 'true') + }) +}) + +test.describe('ExtensionManager Filter acceptance', () => { + test('responds to the manager container width instead of the viewport width', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.evaluate((element) => { + element.style.width = '440px' + }) + + const tagBox = await manager.getByTestId('filter-tag').boundingBox() + const searchBox = await manager.getByTestId('filter-search').boundingBox() + + expect(tagBox).not.toBeNull() + expect(searchBox).not.toBeNull() + expect(searchBox!.y).toBeGreaterThan(tagBox!.y) + expect(searchBox!.x).toBe(tagBox!.x) + }) + + test('filters by trimmed case-insensitive name and description search', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const search = manager.getByTestId('filter-search') + + await search.fill(' ALPHA ') + await expect(manager.locator('li[data-card-id="alpha"]')).toHaveCount(1) + await expect(manager.locator('li[data-card-id="beta"]')).toHaveCount(0) + await search.fill('DESCRIPTION') + await expect(manager.locator('li[data-card-id]')).toHaveCount(3) + }) + + test('shows a themed search clear button only when needed and clears the query', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const search = manager.getByTestId('filter-search') + const clear = manager.getByTestId('filter-search-clear') + + await expect(clear).toHaveCount(0) + await search.fill('alpha') + await expect(clear).toBeVisible() + await clear.click() + await expect(search).toHaveValue('') + await expect(clear).toHaveCount(0) + }) + + test('combines selected tag and search with AND semantics', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-tag').selectOption('recommended') + await manager.getByTestId('filter-search').fill('gamma') + + await expect(manager.locator('li[data-card-id="gamma"]')).toHaveCount(1) + await expect(manager.locator('li[data-card-id="alpha"]')).toHaveCount(0) + await expect(manager.locator('li[data-card-id="beta"]')).toHaveCount(0) + }) + + test('preserves order and installed/available partitioning after filtering', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-search').fill('description') + const availableIds = await manager + .locator('[data-section-key="available"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + await expect(manager.locator('[data-section-key="installed"] li[data-card-id]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"] li[data-card-id]')).toHaveCount(2) + expect(availableIds).toEqual(['beta', 'gamma']) + }) + + test('derives item tags, disables the selector without tags, and keeps the filter row for empty tabs', async ({ + mount, + }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.getByTestId('filter-tag').locator('option')).toHaveCount(3) + await manager.getByTestId('filter-tag').selectOption('recommended') + await component.getByTestId('remove-recommended-tag').click() + await manager.getByTestId('filter-tag').selectOption('writing') + await component.getByTestId('remove-writing-tag').click() + await expect(manager.getByTestId('filter-tag')).toBeDisabled() + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('filter-tag')).toBeEnabled() + await component.getByTestId('empty-library').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + await component.getByTestId('remove-market-tab').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + }) + + test('isolates tab filter state and clears invalid or removed tab state', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-tag').selectOption('recommended') + await manager.getByTestId('filter-search').fill('alpha') + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('filter-search')).toHaveValue('') + await component.getByTestId('set-active-library').click() + await component.getByTestId('remove-recommended-tag').click() + await expect(manager.getByTestId('filter-tag')).toHaveValue('') + await expect(manager.getByTestId('filter-search')).toHaveValue('alpha') + await component.getByTestId('remove-market-tab').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + }) + + test('renders the configured title', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await expect(component.getByTestId('manager-host').getByText('Extension manager', { exact: true })).toBeVisible() + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue b/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue new file mode 100644 index 000000000..2d177995a --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts b/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts new file mode 100644 index 000000000..bebe6b5a6 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionManagerNamespaceFixture from './ExtensionManagerNamespace.fixture.vue' + +test.describe('ExtensionManager namespace', () => { + test('exposes the namespace while Manager, Card, and CardGrid install independently', async ({ mount }) => { + const component = await mount(ExtensionManagerNamespaceFixture) + + await expect(component.getByTestId('manager-surface')).toBeVisible() + await expect(component.getByTestId('manager-name')).toHaveText('TrExtensionManager') + await expect(component.getByTestId('card-name')).toHaveText('TrExtensionCard') + await expect(component.getByTestId('card-grid-name')).toHaveText('TrExtensionCardGrid') + await expect(component.getByTestId('manager-registration')).toHaveText('true') + await expect(component.getByTestId('card-registration')).toHaveText('true') + await expect(component.getByTestId('card-grid-registration')).toHaveText('true') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue b/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue new file mode 100644 index 000000000..fdbd35f4a --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue @@ -0,0 +1,67 @@ + + +