diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 2de45c6a50..23130d3fd3 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -2,7 +2,10 @@
- +
@@ -168,6 +171,151 @@
{{ columns.name }}
+ +
+
+ +
+
+ +

{{ emptyLabel }}

+
+
+
+

+ {{ channel.name }} +

+

+ {{ channel.description || '-' }} +

+
+ +
+
+ + + {{ section.platform }} + + +
+
+
+ {{ columns.groups }} +
+
+
+ + + {{ t('availableChannels.exclusive') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+
+ + + {{ t('availableChannels.public') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+ - +
+
+ +
+
+ {{ columns.supportedModels }} +
+
+ + + {{ noModelsLabel }} + +
+
+
+
+
+
+
diff --git a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts index ecadecf568..82f8c2087f 100644 --- a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts +++ b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts @@ -2,7 +2,19 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { createPinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import AvailableChannelsTable from '../AvailableChannelsTable.vue' +import type { UserAvailableChannel } from '@/api/channels' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AvailableChannelsTable.vue') const componentSource = readFileSync(componentPath, 'utf8') @@ -18,3 +30,150 @@ describe('AvailableChannelsTable scroll integration', () => { expect(componentSource).not.toMatch(/
/) }) }) + +const rows: UserAvailableChannel[] = [ + { + name: 'Primary channel', + description: 'Fast and reliable access', + platforms: [ + { + platform: 'anthropic', + groups: [ + { + id: 1, + name: 'Exclusive Pro', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1.2, + peak_rate_enabled: true, + peak_start: '08:00', + peak_end: '10:00', + peak_rate_multiplier: 1.5, + is_exclusive: true, + }, + { + id: 2, + name: 'Public', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1, + peak_rate_enabled: false, + peak_start: '', + peak_end: '', + peak_rate_multiplier: 1, + is_exclusive: false, + }, + ], + supported_models: [{ name: 'claude-test', platform: 'anthropic', pricing: null }], + }, + ], + }, +] + +const baseProps = { + columns: { + name: 'Channel', + description: 'Description', + platform: 'Platform', + groups: 'Groups and rates', + supportedModels: 'Models and pricing', + }, + rows, + loading: false, + pricingKeyPrefix: 'availableChannels.pricing', + noPricingLabel: 'No pricing', + noModelsLabel: 'No models', + emptyLabel: 'No channels', + userGroupRates: { 1: 0.8 }, +} + +function mountTable(props = {}) { + return mount(AvailableChannelsTable, { + props: { ...baseProps, ...props }, + global: { + plugins: [createPinia()], + stubs: { + Icon: { props: ['name'], template: '' }, + PlatformIcon: { template: '' }, + GroupBadge: { + props: ['name', 'rateMultiplier', 'userRateMultiplier'], + template: + '{{ name }}:{{ rateMultiplier }}:{{ userRateMultiplier }}', + }, + SupportedModelChip: { + props: ['model', 'noPricingLabel'], + template: '{{ model.name }}:{{ noPricingLabel }}', + }, + }, + }, + }) +} + +describe('AvailableChannelsTable responsive surfaces', () => { + it('keeps the five-column table as the desktop-only surface', () => { + const wrapper = mountTable() + const desktop = wrapper.get('[data-testid="desktop-channels"]') + + // TablePageLayout has a more-specific `display: table` rule for descendants, + // so these display utilities must remain important at both breakpoints. + expect(desktop.classes()).toContain('!hidden') + expect(desktop.classes()).toContain('lg:!table') + expect(desktop.findAll('thead th')).toHaveLength(5) + expect(desktop.text()).toContain('Primary channel') + expect(desktop.text()).toContain('Fast and reliable access') + expect(desktop.findAll('[data-group-badge]')).toHaveLength(2) + expect(desktop.get('[data-model-chip]').text()).toContain('claude-test:No pricing') + }) + + it('renders a mobile-only readable surface with groups, rates, peaks, and model pricing chips', () => { + const wrapper = mountTable() + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.classes()).toContain('lg:hidden') + expect(mobile.classes()).toContain('overflow-x-hidden') + expect(mobile.text()).toContain('Primary channel') + expect(mobile.text()).toContain('Fast and reliable access') + expect(mobile.text()).toContain('Groups and rates') + expect(mobile.text()).toContain('Models and pricing') + expect(mobile.text()).toContain('availableChannels.exclusive') + expect(mobile.text()).toContain('availableChannels.public') + expect(mobile.get('[data-group-badge]').text()).toBe('Exclusive Pro:1.2:0.8') + expect(mobile.findAll('[data-group-badge]')).toHaveLength(2) + expect(mobile.get('[data-icon="clock"]')).toBeTruthy() + expect(mobile.text()).toContain('08:00') + expect(mobile.text()).toContain('10:00') + expect(mobile.text()).toContain('×1.5') + expect(mobile.get('[data-model-chip]').text()).toBe('claude-test:No pricing') + expect(mobile.findAll('.max-w-full')).not.toHaveLength(0) + }) + + it('keeps the mobile placeholders when a platform has no groups or models', () => { + const wrapper = mountTable({ + rows: [ + { + name: 'Fallback channel', + description: '', + platforms: [{ platform: 'openai', groups: [], supported_models: [] }], + }, + ], + }) + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.text()).toContain('Fallback channel') + expect(mobile.text()).toContain('openai') + expect(mobile.text()).toContain('No models') + expect(mobile.findAll('dd')[0].text()).toBe('-') + }) + + it('provides loading and empty states on both responsive surfaces', async () => { + const wrapper = mountTable({ loading: true, rows: [] }) + + expect(wrapper.get('[data-testid="desktop-channels"] [data-icon="refresh"]')).toBeTruthy() + expect(wrapper.get('[data-testid="mobile-loading"] [data-icon="refresh"]')).toBeTruthy() + + await wrapper.setProps({ loading: false }) + + expect(wrapper.get('[data-testid="desktop-channels"]').text()).toContain('No channels') + expect(wrapper.get('[data-testid="mobile-empty"]').text()).toContain('No channels') + }) +})