feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconAlipay({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('fill-current', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Alipay</title>
|
||||
<path d='M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { IconAlipay } from './icon-alipay'
|
||||
export { IconDiscord } from './icon-discord'
|
||||
export { IconDocker } from './icon-docker'
|
||||
export { IconFacebook } from './icon-facebook'
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
|
||||
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { STATUS_QUERY_KEY } from '@/lib/status-query'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { CommandMenu } from '../command-menu'
|
||||
import { Search } from '../search'
|
||||
|
||||
const adminEntries = ['Channels', 'Models', 'Users', 'Redemption Codes']
|
||||
const superAdminEntries = ['System Info', 'Task Plugins', 'System Settings']
|
||||
|
||||
// jsdom lacks the Web Animations API the menu's scroll area waits on.
|
||||
const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations'
|
||||
)
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (getAnimationsDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations',
|
||||
getAnimationsDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
async function searchEntriesFor(role: number, sidebarModulesAdmin = '') {
|
||||
useAuthStore.getState().auth.setUser({ id: 1, username: 'alice', role })
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
client.setQueryData(STATUS_QUERY_KEY, {
|
||||
SidebarModulesAdmin: sidebarModulesAdmin,
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: createRootRoute({
|
||||
component: () => (
|
||||
<SearchProvider>
|
||||
<Search />
|
||||
<CommandMenu />
|
||||
</SearchProvider>
|
||||
),
|
||||
}),
|
||||
history: createMemoryHistory({ initialEntries: ['/dashboard/overview'] }),
|
||||
})
|
||||
await router.load()
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Search' }))
|
||||
const options = await screen.findAllByRole('option')
|
||||
return options.map((option) => option.textContent)
|
||||
}
|
||||
|
||||
test('regular users only find their own console pages in search', async () => {
|
||||
const entries = await searchEntriesFor(ROLE.USER)
|
||||
|
||||
expect(entries).toEqual(
|
||||
expect.arrayContaining(['API Keys', 'Usage Logs', 'Wallet', 'Profile'])
|
||||
)
|
||||
for (const entry of [...adminEntries, ...superAdminEntries]) {
|
||||
expect(entries).not.toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
test('admins find admin pages in search but not super admin pages', async () => {
|
||||
const entries = await searchEntriesFor(ROLE.ADMIN)
|
||||
|
||||
expect(entries).toEqual(expect.arrayContaining(adminEntries))
|
||||
for (const entry of superAdminEntries) {
|
||||
expect(entries).not.toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
test('pages hidden in the sidebar settings stay out of search', async () => {
|
||||
const entries = await searchEntriesFor(
|
||||
ROLE.USER,
|
||||
JSON.stringify({ personal: { enabled: true, topup: false } })
|
||||
)
|
||||
|
||||
expect(entries).not.toContain('Wallet')
|
||||
expect(entries).toContain('Profile')
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import i18next from 'i18next'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { DatePicker } from '../date-picker'
|
||||
import { DateTimePicker } from '../datetime-picker'
|
||||
|
||||
vi.mock('@/components/ui/calendar', () => ({
|
||||
Calendar: ({ locale }: { locale?: { code?: string } }) => (
|
||||
<div data-testid='calendar-locale'>{locale?.code ?? 'missing'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const selected = new Date('2026-10-11T00:00:00')
|
||||
|
||||
async function setInterfaceLanguage(language: string) {
|
||||
if (!i18next.hasResourceBundle(language, 'translation')) {
|
||||
i18next.addResourceBundle(language, 'translation', {})
|
||||
}
|
||||
await act(() => i18next.changeLanguage(language))
|
||||
}
|
||||
|
||||
async function openCalendar() {
|
||||
await userEvent.click(screen.getByRole('button', { name: /2026-10-11/ }))
|
||||
return screen.findByTestId('calendar-locale')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => i18next.changeLanguage('en'))
|
||||
})
|
||||
|
||||
test('uses Chinese calendar locales for zhCN and zhTW in both date pickers', async () => {
|
||||
await setInterfaceLanguage('zhCN')
|
||||
const datePicker = render(
|
||||
<DatePicker selected={selected} onSelect={() => undefined} />
|
||||
)
|
||||
expect(await openCalendar()).toHaveTextContent('zh-CN')
|
||||
datePicker.unmount()
|
||||
|
||||
const dateTimePicker = render(
|
||||
<DateTimePicker value={selected} onChange={() => undefined} />
|
||||
)
|
||||
expect(await openCalendar()).toHaveTextContent('zh-CN')
|
||||
dateTimePicker.unmount()
|
||||
|
||||
await setInterfaceLanguage('zhTW')
|
||||
render(<DatePicker selected={selected} onSelect={() => undefined} />)
|
||||
expect(await openCalendar()).toHaveTextContent('zh-TW')
|
||||
})
|
||||
|
||||
test('keeps French on the French calendar locale', async () => {
|
||||
await setInterfaceLanguage('fr')
|
||||
render(<DatePicker selected={selected} onSelect={() => undefined} />)
|
||||
expect(await openCalendar()).toHaveTextContent('fr')
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import { DateTimePicker } from '../datetime-picker'
|
||||
|
||||
test('shows the seconds of the selected time', () => {
|
||||
render(
|
||||
<DateTimePicker
|
||||
value={new Date(2026, 9, 11, 8, 30, 45)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByLabelText('Time')).toHaveValue('08:30:45')
|
||||
})
|
||||
|
||||
test('keeps the seconds typed into the time field', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<DateTimePicker
|
||||
value={new Date(2026, 9, 11, 8, 0, 0)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('Time'), {
|
||||
target: { value: '08:30:45' },
|
||||
})
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 8, 30, 45))
|
||||
})
|
||||
|
||||
test('reads a time without seconds as zero seconds', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<DateTimePicker
|
||||
value={new Date(2026, 9, 11, 8, 0, 30)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('Time'), {
|
||||
target: { value: '09:15' },
|
||||
})
|
||||
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 9, 15, 0))
|
||||
})
|
||||
|
||||
test('keeps the current time when the time field is emptied', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<DateTimePicker
|
||||
value={new Date(2026, 9, 11, 20, 30, 45)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
const field = screen.getByLabelText('Time')
|
||||
fireEvent.change(field, { target: { value: '' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(field).toHaveValue('20:30:45')
|
||||
})
|
||||
@@ -40,6 +40,25 @@ function editorTree(value: string) {
|
||||
}
|
||||
|
||||
describe('CodeBlockEditor', () => {
|
||||
test('does not add a second vertical inset to the line-number gutter for multiline source', () => {
|
||||
const { getByRole } = render(
|
||||
<CodeBlockEditor
|
||||
ariaLabel='Plugin source'
|
||||
autoFocus={false}
|
||||
language='javascript'
|
||||
onChange={() => undefined}
|
||||
value={'2\n1\n1'}
|
||||
/>
|
||||
)
|
||||
|
||||
const editor = getByRole('textbox', { name: 'Plugin source' })
|
||||
const gutters = editor.querySelector('.cm-gutters')
|
||||
|
||||
// CodeMirror already offsets each gutter line by the content padding.
|
||||
// Extra vertical padding here shifts every number below its source line.
|
||||
expect(gutters).toHaveStyle({ paddingTop: '0px', paddingBottom: '0px' })
|
||||
})
|
||||
|
||||
test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
|
||||
const { rerender } = render(editorTree('h'))
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ const codeMirrorTheme = EditorView.theme({
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5rem',
|
||||
padding: '1rem 1rem 1rem 0',
|
||||
// CodeMirror already accounts for the content's vertical padding.
|
||||
padding: '0 1rem 0 0',
|
||||
},
|
||||
'.cm-gutters:empty': {
|
||||
display: 'none',
|
||||
|
||||
@@ -889,7 +889,7 @@ export const PromptInputTextarea = ({
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
className={cn('field-sizing-content max-h-48 min-h-16', className)}
|
||||
className={cn('field-sizing-content max-h-48 min-h-16 break-all', className)}
|
||||
name='message'
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
Cancel01Icon,
|
||||
Drag01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Reorder, useDragControls } from 'motion/react'
|
||||
import type { KeyboardEvent, PointerEvent, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type AutoGroupOrderItemProps = {
|
||||
group: string
|
||||
index: number
|
||||
count: number
|
||||
leading?: ReactNode
|
||||
children?: ReactNode
|
||||
onMove: (index: number, direction: 'up' | 'down') => void
|
||||
onRemove: (group: string) => void
|
||||
}
|
||||
|
||||
export function AutoGroupOrderItem(props: AutoGroupOrderItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const dragControls = useDragControls()
|
||||
|
||||
const handleDragStart = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
dragControls.start(event)
|
||||
}
|
||||
|
||||
const handleDragKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
props.onMove(props.index, 'up')
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
props.onMove(props.index, 'down')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={props.group}
|
||||
dragListener={false}
|
||||
dragMomentum={false}
|
||||
dragControls={dragControls}
|
||||
className='bg-background relative flex min-w-0 items-center gap-2 rounded-lg border p-2'
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='text-muted-foreground cursor-grab touch-none font-mono active:cursor-grabbing'
|
||||
aria-label={t('Drag {{group}} to reorder', { group: props.group })}
|
||||
title={t('Drag {{group}} to reorder', { group: props.group })}
|
||||
onPointerDown={handleDragStart}
|
||||
onKeyDown={handleDragKeyDown}
|
||||
>
|
||||
<HugeiconsIcon icon={Drag01Icon} strokeWidth={2} aria-hidden='true' />
|
||||
</Button>
|
||||
{props.leading}
|
||||
<div className='flex min-w-0 flex-1 flex-wrap items-center gap-2'>
|
||||
<span
|
||||
className='min-w-0 truncate text-sm font-medium'
|
||||
title={props.group}
|
||||
>
|
||||
{props.group}
|
||||
</span>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className='flex shrink-0 gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
disabled={props.index === 0}
|
||||
aria-label={t('Move {{group}} up', { group: props.group })}
|
||||
onClick={() => props.onMove(props.index, 'up')}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowUp01Icon}
|
||||
strokeWidth={2}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
disabled={props.index === props.count - 1}
|
||||
aria-label={t('Move {{group}} down', { group: props.group })}
|
||||
onClick={() => props.onMove(props.index, 'down')}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
strokeWidth={2}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Remove {{group}}', { group: props.group })}
|
||||
onClick={() => props.onRemove(props.group)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
strokeWidth={2}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,9 +33,8 @@ import {
|
||||
} from '@/components/ui/command'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
import { useSidebarView } from '@/hooks/use-sidebar-view'
|
||||
|
||||
import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
@@ -43,12 +42,9 @@ export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const { pathname } = useLocation()
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// Use the active nested sidebar view's nav groups when one matches
|
||||
// the current URL; otherwise fall back to the root navigation.
|
||||
const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups
|
||||
// Offer the same pages as the sidebar, so search never lists pages the
|
||||
// user's role or the sidebar settings hide.
|
||||
const { navGroups } = useSidebarView()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
@@ -67,11 +63,11 @@ export function CommandMenu() {
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
{navGroups.map((group) => (
|
||||
<CommandGroup key={group.id || group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
{group.items.map((navItem) => {
|
||||
if (navItem.url) {
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
key={`${navItem.title}-${navItem.url}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
@@ -83,10 +79,11 @@ export function CommandMenu() {
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
return navItem.items?.map((subItem) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
key={`${navItem.title}-${subItem.url}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useThemeCustomization } from '@/context/theme-customization-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
type ContentLayout,
|
||||
resolveThemeFont,
|
||||
THEME_PRESETS,
|
||||
type ThemeFont,
|
||||
type ThemePreset,
|
||||
@@ -305,28 +306,26 @@ function PresetConfig() {
|
||||
/**
|
||||
* Font options shown in the theme drawer.
|
||||
*
|
||||
* Each option renders a live "Aa" preview in the font it represents.
|
||||
* `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
|
||||
* the currently active body font — that way the user sees what `Auto` will
|
||||
* actually look like for the active preset (Anthropic → serif glyphs,
|
||||
* everything else → sans glyphs) without us having to duplicate the
|
||||
* preset-default mapping in the UI.
|
||||
* Each option previews the font it will select, including Auto when the
|
||||
* user currently has an explicit font selected.
|
||||
*/
|
||||
const FONT_OPTIONS: {
|
||||
value: ThemeFont
|
||||
label: string
|
||||
// CSS font-family applied to the "Aa" preview. `undefined` = inherit
|
||||
// from the current theme (used by the `default` option).
|
||||
// Auto resolves its preview from the active preset.
|
||||
preview?: string
|
||||
}[] = [
|
||||
{ value: 'default', label: 'Auto', preview: undefined },
|
||||
{ value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
|
||||
{ value: 'sans', label: 'Sans', preview: 'var(--font-public-sans)' },
|
||||
{ value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
|
||||
]
|
||||
|
||||
function FontConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setFont } = useThemeCustomization()
|
||||
const autoFont = resolveThemeFont('default', customization.preset)
|
||||
const autoFontFamily =
|
||||
autoFont === 'serif' ? 'var(--font-serif)' : 'sans-serif'
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
@@ -367,13 +366,7 @@ function FontConfig() {
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
|
||||
style={
|
||||
option.preview
|
||||
? { fontFamily: option.preview }
|
||||
: // `font: inherit` defers to the active theme so the
|
||||
// "Auto" tile previews what the resolved font will be.
|
||||
{ font: 'inherit', fontSize: '1.125rem' }
|
||||
}
|
||||
style={{ fontFamily: option.preview ?? autoFontFamily }}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
|
||||
@@ -63,6 +63,7 @@ function DataTableRowInner<TData>({
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-sortable-id={row.id}
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={className}
|
||||
{...rowProps}
|
||||
|
||||
@@ -183,6 +183,7 @@ export function DataTableCardGrid<TData>(props: DataTableCardGridProps<TData>) {
|
||||
<div
|
||||
key={key}
|
||||
data-slot='data-table-card'
|
||||
data-sortable-id={row.id}
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className={cn(
|
||||
'rounded-lg border bg-(--data-table-card-bg,var(--table-row)) px-3 py-2.5 transition-[background-color,border-color] duration-150 data-[state=selected]:[--data-table-card-bg:color-mix(in_oklch,var(--primary)_7%,var(--table-row))] data-[state=selected]:border-primary/40',
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -31,7 +31,8 @@ import dayjs from '@/lib/dayjs'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
zhCN,
|
||||
zhTW,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
|
||||
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { enUS, fr, ja, ru, vi, zhCN, zhTW } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -34,7 +34,8 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
zhCN,
|
||||
zhTW,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
@@ -62,23 +63,21 @@ export function DateTimePicker({
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [date, setDate] = React.useState<Date | undefined>(value)
|
||||
const [month, setMonth] = React.useState<Date | undefined>(value)
|
||||
const [time, setTime] = React.useState<string>('00:00')
|
||||
const [time, setTime] = React.useState<string>('00:00:00')
|
||||
|
||||
React.useEffect(() => {
|
||||
setDate(value)
|
||||
setMonth(value)
|
||||
if (value) {
|
||||
const hours = value.getHours().toString().padStart(2, '0')
|
||||
const minutes = value.getMinutes().toString().padStart(2, '0')
|
||||
setTime(`${hours}:${minutes}`)
|
||||
setTime(dayjs(value).format('HH:mm:ss'))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const [hours, minutes, seconds = 0] = time.split(':').map(Number)
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
newDate.setHours(hours, minutes, seconds, 0)
|
||||
setDate(newDate)
|
||||
setMonth(newDate)
|
||||
onChange?.(newDate)
|
||||
@@ -92,12 +91,16 @@ export function DateTimePicker({
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
// The browser reports an emptied or half-cleared field as ''. Keep the
|
||||
// current time: the field shows it again and no invalid date goes out.
|
||||
if (!newTime) return
|
||||
setTime(newTime)
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes] = newTime.split(':').map(Number)
|
||||
// A browser may omit :00 seconds from the value.
|
||||
const [hours, minutes, seconds = 0] = newTime.split(':').map(Number)
|
||||
const newDate = new Date(date)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
newDate.setHours(hours, minutes, seconds, 0)
|
||||
setDate(newDate)
|
||||
onChange?.(newDate)
|
||||
}
|
||||
@@ -106,7 +109,7 @@ export function DateTimePicker({
|
||||
const handleClear = () => {
|
||||
setDate(undefined)
|
||||
setMonth(undefined)
|
||||
setTime('00:00')
|
||||
setTime('00:00:00')
|
||||
onChange?.(undefined)
|
||||
}
|
||||
|
||||
@@ -143,7 +146,9 @@ export function DateTimePicker({
|
||||
</Popover>
|
||||
<Input
|
||||
type='time'
|
||||
step={1}
|
||||
value={time}
|
||||
aria-label={t('Time')}
|
||||
onChange={handleTimeChange}
|
||||
className='w-32 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
|
||||
disabled={!date}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function Dialog({
|
||||
{trigger ? <DialogTrigger render={trigger} /> : null}
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
|
||||
'flex w-full flex-col gap-4 overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6',
|
||||
contentClassName,
|
||||
dialogContentMotionClassName
|
||||
)}
|
||||
@@ -97,7 +97,7 @@ export function Dialog({
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
|
||||
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
|
||||
'h-[var(--dialog-content-height)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowExpand02Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -60,6 +62,8 @@ type FloatingWindowProps = {
|
||||
* key so the window reopens where and how the user left it.
|
||||
*/
|
||||
storageKey?: string
|
||||
/** A new request expands the window without resetting its position or size. */
|
||||
expandRequest?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -88,7 +92,7 @@ const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
|
||||
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
|
||||
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
|
||||
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
|
||||
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
|
||||
se: 'bottom-0 right-0 size-8 cursor-nwse-resize',
|
||||
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
|
||||
}
|
||||
|
||||
@@ -199,7 +203,8 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
size.width
|
||||
),
|
||||
size,
|
||||
collapsed: stored?.collapsed ?? false,
|
||||
collapsed:
|
||||
props.expandRequest === undefined && (stored?.collapsed ?? false),
|
||||
}
|
||||
})
|
||||
const windowRef = useRef<HTMLElement>(null)
|
||||
@@ -217,6 +222,13 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
size: { width: number; height: number }
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expandRequest === undefined) return
|
||||
setState((previous) =>
|
||||
previous.collapsed ? { ...previous, collapsed: false } : previous
|
||||
)
|
||||
}, [props.expandRequest])
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.storageKey) return
|
||||
try {
|
||||
@@ -455,6 +467,9 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
{props.footer}
|
||||
</footer>
|
||||
)}
|
||||
<div className='border-border/50 bg-muted/30 text-muted-foreground flex h-8 shrink-0 items-center justify-end border-t pr-9 pl-4 text-xs select-none'>
|
||||
{t('Drag to resize')}
|
||||
</div>
|
||||
{RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
@@ -464,13 +479,23 @@ export function FloatingWindow(props: FloatingWindowProps) {
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute z-10 touch-none',
|
||||
RESIZE_HANDLE_CLASS[direction]
|
||||
RESIZE_HANDLE_CLASS[direction],
|
||||
direction === 'se' &&
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground flex items-center justify-center rounded-tl-md'
|
||||
)}
|
||||
onPointerDown={(event) => handleResizeStart(event, direction)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
/>
|
||||
>
|
||||
{direction === 'se' && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowExpand02Icon}
|
||||
className='pointer-events-none size-4'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -76,10 +76,13 @@ function resizeHandle(direction: string) {
|
||||
return handle
|
||||
}
|
||||
|
||||
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
|
||||
test('the visible resize hint leads to a bottom-right grip that grows both dimensions and persists them', () => {
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
const handle = resizeHandle('se')
|
||||
expect(screen.getByText('Drag to resize')).toBeVisible()
|
||||
expect(handle.querySelector('svg')).toBeVisible()
|
||||
expect(handle).toHaveClass('size-8', 'cursor-nwse-resize')
|
||||
fireEvent.pointerDown(handle, {
|
||||
button: 0,
|
||||
pointerId: 3,
|
||||
@@ -145,11 +148,13 @@ test('collapses to a pill that keeps the title and reopens, and the close contro
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Scratch pad')).toBeVisible()
|
||||
expect(screen.queryByText('body')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Drag to resize')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toBeVisible()
|
||||
expect(screen.getByText('Drag to resize')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Close panel' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
@@ -54,6 +55,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<CommandMenu />
|
||||
</SearchProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
|
||||
@@ -153,14 +153,7 @@ export function ServiceSupportAttribution() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className='text-muted-foreground/60 text-xs'>
|
||||
<a
|
||||
href='https://www.crash.work/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
|
||||
>
|
||||
{t('Service support provided by Crash Workshop Cloud Computing')}
|
||||
</a>
|
||||
CrashWork 破碎工坊
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
</h2>
|
||||
</div>
|
||||
{actions != null && (
|
||||
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
|
||||
<div className='flex max-w-full shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -47,10 +47,7 @@ export { defaultTopNavLinks } from './config/top-nav.config'
|
||||
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
|
||||
|
||||
// Sidebar view registry
|
||||
export {
|
||||
getNavGroupsForPath,
|
||||
resolveSidebarView,
|
||||
} from './lib/sidebar-view-registry'
|
||||
export { resolveSidebarView } from './lib/sidebar-view-registry'
|
||||
|
||||
// Type exports (type-only to avoid conflicts with components above)
|
||||
export type {
|
||||
|
||||
@@ -16,10 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
|
||||
import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config'
|
||||
import type { NavGroup, SidebarView } from '../types'
|
||||
import type { SidebarView } from '../types'
|
||||
|
||||
/**
|
||||
* Registered nested sidebar views.
|
||||
@@ -41,19 +39,3 @@ const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW]
|
||||
export function resolveSidebarView(pathname: string): SidebarView | null {
|
||||
return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible helper for consumers (e.g. command palette) that
|
||||
* just need the navigation groups for the current path, without caring
|
||||
* about the view metadata.
|
||||
*
|
||||
* @returns Nav groups for the matched view, or `null` if no nested view
|
||||
* matches (callers should then fall back to root nav groups).
|
||||
*/
|
||||
export function getNavGroupsForPath(
|
||||
pathname: string,
|
||||
t: TFunction
|
||||
): NavGroup[] | null {
|
||||
const view = resolveSidebarView(pathname)
|
||||
return view ? view.getNavGroups(t) : null
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { formatGroupDetail } from './model-group-selector/group-detail'
|
||||
import {
|
||||
modelGroupSelectorLayoutClasses,
|
||||
scrollSelectedOptionIntoView,
|
||||
@@ -77,7 +78,7 @@ interface ModelOption {
|
||||
interface GroupOption {
|
||||
label: string
|
||||
value: string
|
||||
ratio?: number
|
||||
ratio?: number | string
|
||||
desc?: string
|
||||
description?: string
|
||||
}
|
||||
@@ -440,13 +441,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
</span>
|
||||
{(group.desc || group.description) && (
|
||||
<div className='text-muted-foreground truncate text-[9px] leading-tight'>
|
||||
{group.desc || group.description}
|
||||
{group.ratio && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('Ratio: {{value}}', { value: group.ratio })}
|
||||
</>
|
||||
)}
|
||||
{formatGroupDetail(group, t)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -500,15 +495,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
</span>
|
||||
{(group.desc || group.description) && (
|
||||
<div className='text-muted-foreground mt-0.5 text-xs'>
|
||||
{group.desc || group.description}
|
||||
{group.ratio && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('Ratio: {{value}}', {
|
||||
value: group.ratio,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{formatGroupDetail(group, t)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createInstance } from 'i18next'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { formatGroupDetail } from '../group-detail'
|
||||
|
||||
describe('model group selector group detail', () => {
|
||||
test('translates the backend description key and the auto ratio when the language is Chinese', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({
|
||||
lng: 'zh',
|
||||
resources: {
|
||||
zh: {
|
||||
translation: {
|
||||
'User group': '用户分组',
|
||||
Auto: '自动',
|
||||
'Ratio: {{value}}': '倍率:{{value}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
formatGroupDetail({ desc: 'User group', ratio: 'auto' }, i18n.t)
|
||||
).toBe('用户分组 · 倍率:自动')
|
||||
})
|
||||
|
||||
test('keeps an administrator description and a numeric ratio as written', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({ lng: 'en', resources: { en: { translation: {} } } })
|
||||
|
||||
expect(formatGroupDetail({ desc: 'VIP 专线', ratio: 1.5 }, i18n.t)).toBe(
|
||||
'VIP 专线 · Ratio: 1.5'
|
||||
)
|
||||
expect(formatGroupDetail({ description: 'No ratio' }, i18n.t)).toBe(
|
||||
'No ratio'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { translateServerText } from '@/lib/server-error-message'
|
||||
|
||||
export interface GroupDetailSource {
|
||||
ratio?: number | string
|
||||
desc?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under a group name: its description and billing ratio. Built-in
|
||||
* descriptions arrive as English source keys, and the auto group has a
|
||||
* non-numeric ratio.
|
||||
*/
|
||||
export function formatGroupDetail(
|
||||
group: GroupDetailSource,
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
): string {
|
||||
const description = group.desc
|
||||
? translateServerText(t, group.desc)
|
||||
: (group.description ?? '')
|
||||
if (!group.ratio) return description
|
||||
const value = typeof group.ratio === 'number' ? group.ratio : t('Auto')
|
||||
return `${description} · ${t('Ratio: {{value}}', { value })}`
|
||||
}
|
||||
@@ -47,6 +47,11 @@ export type Option = {
|
||||
* values also carry a marker icon whose tooltip repeats the hint.
|
||||
*/
|
||||
hint?: string
|
||||
/**
|
||||
* Leading icon rendered before the label in the dropdown and on the chip.
|
||||
* Decorative only: it never changes the accessible name.
|
||||
*/
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
@@ -153,6 +158,14 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
return map
|
||||
}, [props.options])
|
||||
|
||||
const iconMap = React.useMemo(() => {
|
||||
const map = new Map<string, React.ReactNode>()
|
||||
for (const option of props.options) {
|
||||
if (option.icon) map.set(option.value, option.icon)
|
||||
}
|
||||
return map
|
||||
}, [props.options])
|
||||
|
||||
const trimmedInput = inputValue.trim()
|
||||
const inputMatchesExisting =
|
||||
trimmedInput.length > 0 &&
|
||||
@@ -316,8 +329,17 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
{visibleValues.map((value) => {
|
||||
const label = labelMap.get(value) ?? value
|
||||
const hint = hintMap.get(value)
|
||||
const icon = iconMap.get(value)
|
||||
return (
|
||||
<ComboboxChip key={value}>
|
||||
{icon && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-flex shrink-0'
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{props.copyChipOnClick ? (
|
||||
<button
|
||||
type='button'
|
||||
@@ -401,6 +423,7 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
const isCreate = canCreate && item === trimmedInput
|
||||
const label = labelMap.get(item) ?? item
|
||||
const hint = hintMap.get(item)
|
||||
const icon = iconMap.get(item)
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={item}
|
||||
@@ -424,6 +447,14 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{icon && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-flex shrink-0'
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className='truncate'>{label}</span>
|
||||
{hint && (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
|
||||
test('option icons render beside the label on chips and in the dropdown without changing accessible names', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<MultiSelect
|
||||
options={[
|
||||
{
|
||||
value: 'doubao',
|
||||
label: 'Doubao',
|
||||
icon: <span data-testid='icon-doubao' />,
|
||||
},
|
||||
{ value: 'plain', label: 'plain' },
|
||||
]}
|
||||
selected={['doubao']}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
const chipIcon = screen.getByTestId('icon-doubao')
|
||||
expect(chipIcon).toBeVisible()
|
||||
expect(chipIcon.parentElement).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByText('Doubao')).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
const option = screen.getByRole('option', { name: 'Doubao' })
|
||||
expect(within(option).getByTestId('icon-doubao')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('option', { name: 'plain' })).queryByTestId(/icon-/)
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, ChevronDown, Info } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type {
|
||||
AdminPermissionMatrix,
|
||||
PermissionResourceDef,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type PermissionMatrixGroup = {
|
||||
key: string
|
||||
labelKey: string
|
||||
resources: PermissionResourceDef[]
|
||||
}
|
||||
|
||||
type PermissionMatrixProps = {
|
||||
value: AdminPermissionMatrix
|
||||
onChange: (value: AdminPermissionMatrix) => void
|
||||
disabled?: boolean
|
||||
} & (
|
||||
| { resources: PermissionResourceDef[] }
|
||||
// Groups render as collapsible sections with a selection count and a
|
||||
// select-all action.
|
||||
| { groups: PermissionMatrixGroup[] }
|
||||
)
|
||||
|
||||
type PermissionResourceRowProps = {
|
||||
idPrefix: string
|
||||
resource: PermissionResourceDef
|
||||
value: AdminPermissionMatrix
|
||||
onChange: (value: AdminPermissionMatrix) => void
|
||||
disabled?: boolean
|
||||
className: string
|
||||
}
|
||||
|
||||
// PermissionMatrix renders one toggle per resource action of a permission
|
||||
// catalog. Each resource lists its action descriptions in a tooltip, and the
|
||||
// toggles announce them as their descriptions.
|
||||
export function PermissionMatrix(props: PermissionMatrixProps) {
|
||||
const { t } = useTranslation()
|
||||
const idPrefix = useId()
|
||||
if (!('groups' in props)) {
|
||||
return (
|
||||
<div className='divide-y rounded-md border'>
|
||||
{props.resources.map((resource) => (
|
||||
<PermissionResourceRow
|
||||
key={resource.resource}
|
||||
idPrefix={idPrefix}
|
||||
resource={resource}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
className='px-3'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='divide-y rounded-md border'>
|
||||
{props.groups.map((group) => {
|
||||
const label = t(group.labelKey)
|
||||
const cells = group.resources.flatMap((resource) =>
|
||||
resource.actions.map(
|
||||
(option) => props.value[resource.resource]?.[option.action] === true
|
||||
)
|
||||
)
|
||||
const selected = cells.filter(Boolean).length
|
||||
const allSelected = selected === cells.length
|
||||
return (
|
||||
<section key={group.key} aria-label={label}>
|
||||
<Collapsible defaultOpen>
|
||||
<div className='flex items-center gap-2 px-3 py-2'>
|
||||
<CollapsibleTrigger className='group/trigger flex flex-1 items-center gap-2 text-left text-sm font-medium'>
|
||||
<ChevronDown
|
||||
className='text-muted-foreground size-4 -rotate-90 transition-transform group-data-[panel-open]/trigger:rotate-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{label}
|
||||
<Badge variant='secondary' className='tabular-nums'>
|
||||
{t('Selected {{selected}} / {{total}}', {
|
||||
selected,
|
||||
total: cells.length,
|
||||
})}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
const next = { ...props.value }
|
||||
for (const resource of group.resources) {
|
||||
const actions = { ...next[resource.resource] }
|
||||
for (const option of resource.actions) {
|
||||
actions[option.action] = !allSelected
|
||||
}
|
||||
next[resource.resource] = actions
|
||||
}
|
||||
props.onChange(next)
|
||||
}}
|
||||
>
|
||||
{allSelected ? t('Clear') : t('Select all')}
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className='divide-y border-t'>
|
||||
{group.resources.map((resource) => (
|
||||
<PermissionResourceRow
|
||||
key={resource.resource}
|
||||
idPrefix={idPrefix}
|
||||
resource={resource}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
className='pr-3 pl-9'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionResourceRow(props: PermissionResourceRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const resource = props.resource
|
||||
const label = t(resource.label_key)
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={label}
|
||||
className={cn('flex items-start gap-3 py-2', props.className)}
|
||||
>
|
||||
<div className='flex w-32 shrink-0 items-center gap-1.5 pt-1 text-sm'>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground hover:text-foreground inline-flex'
|
||||
aria-label={t('Details')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className='size-3.5' aria-hidden='true' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='block max-w-sm py-2 text-left'>
|
||||
<dl className='space-y-1.5'>
|
||||
{resource.actions.map((option) => (
|
||||
<div key={option.action}>
|
||||
<dt className='font-medium'>{t(option.label_key)}</dt>
|
||||
<dd className='opacity-80'>{t(option.description_key)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-wrap gap-1.5'>
|
||||
{resource.actions.map((option) => {
|
||||
const pressed =
|
||||
props.value[resource.resource]?.[option.action] === true
|
||||
const descriptionId = `${props.idPrefix}-${resource.resource}-${option.action}`
|
||||
return (
|
||||
<Toggle
|
||||
key={option.action}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
pressed={pressed}
|
||||
disabled={props.disabled}
|
||||
aria-describedby={descriptionId}
|
||||
onPressedChange={(next) => {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
[resource.resource]: {
|
||||
...props.value[resource.resource],
|
||||
[option.action]: next,
|
||||
},
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
'h-auto min-h-7 max-w-full font-normal whitespace-normal',
|
||||
pressed &&
|
||||
'border-primary bg-primary/10! text-primary hover:bg-primary/15'
|
||||
)}
|
||||
>
|
||||
{pressed && <Check aria-hidden='true' />}
|
||||
{t(option.label_key)}
|
||||
<span id={descriptionId} hidden>
|
||||
{t(option.description_key)}
|
||||
</span>
|
||||
</Toggle>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Dialog as DialogRoot, DialogContent, DialogTitle } from '../dialog'
|
||||
|
||||
describe('dialog viewport layout', () => {
|
||||
test('long content shrinks independently of a stacked action footer', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<Dialog
|
||||
open
|
||||
title='System updates'
|
||||
contentHeight='720px'
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onConfirm}>Check again</Button>
|
||||
<Button>Ignore this version</Button>
|
||||
<Button>Go to GitHub</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>Long release notes</p>
|
||||
</Dialog>
|
||||
)
|
||||
const dialog = screen.getByRole('dialog', { name: 'System updates' })
|
||||
// Keep a dynamic-viewport fallback when global CSS variables are missing.
|
||||
expect(dialog).toHaveClass(
|
||||
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]'
|
||||
)
|
||||
expect(dialog).toHaveClass('top-[var(--dialog-viewport-center,50dvh)]')
|
||||
// If header and footer alone exceed a short viewport, the popup itself
|
||||
// must remain scrollable instead of clipping its actions.
|
||||
expect(dialog).toHaveClass('overflow-y-auto', 'overscroll-contain')
|
||||
const body =
|
||||
screen.getByText('Long release notes').parentElement?.parentElement
|
||||
expect(body).toHaveClass('min-h-0', 'overflow-y-auto')
|
||||
expect(body).not.toHaveClass('max-h-[calc(100vh-14rem)]')
|
||||
const footer = dialog.querySelector('[data-slot=dialog-footer]')
|
||||
expect(footer).toHaveClass('flex-shrink-0')
|
||||
expect(body?.contains(footer)).toBe(false)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Check again' }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('unwrapped dialogs bound and scroll long content within the visible viewport', () => {
|
||||
render(
|
||||
<DialogRoot open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Long dialog</DialogTitle>
|
||||
<p>Long content</p>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: 'Long dialog' })).toHaveClass(
|
||||
'top-[var(--dialog-viewport-center,50dvh)]',
|
||||
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]',
|
||||
'overflow-y-auto',
|
||||
'overscroll-contain'
|
||||
)
|
||||
})
|
||||
|
||||
test('confirmation dialogs scroll overflowing content and keep confirmation usable', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Confirm changes'
|
||||
desc='A long confirmation description'
|
||||
handleConfirm={onConfirm}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('alertdialog', { name: 'Confirm changes' })
|
||||
).toHaveClass(
|
||||
'top-[var(--dialog-viewport-center,50dvh)]',
|
||||
'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]',
|
||||
'overflow-y-auto',
|
||||
'overscroll-contain'
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { Markdown } from '../markdown'
|
||||
|
||||
test.each([
|
||||
['inline', 'Formula: $$\\frac{1}{2}$$.'],
|
||||
['display', '$$\n\\frac{1}{2}\n$$'],
|
||||
['fenced', '```math\n\\frac{1}{2}\n```'],
|
||||
])('preserves MathML and visual output when rendering %s math', (_, source) => {
|
||||
const view = render(<Markdown>{source}</Markdown>)
|
||||
const math = view.getByRole('math', { hidden: true })
|
||||
|
||||
expect(math.querySelector('mfrac')).not.toBeNull()
|
||||
expect(math.querySelector('annotation')).toHaveTextContent('\\frac{1}{2}')
|
||||
expect(view.container.querySelector('.katex-html')).toHaveAttribute(
|
||||
'aria-hidden',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
test('does not load math resources when trust is inherited from a polluted prototype', () => {
|
||||
const originalTrust = Object.getOwnPropertyDescriptor(
|
||||
Object.prototype,
|
||||
'trust'
|
||||
)
|
||||
|
||||
try {
|
||||
Object.defineProperty(Object.prototype, 'trust', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const view = render(
|
||||
<Markdown>
|
||||
{'$$\\includegraphics{https://example.com/untrusted.png}$$'}
|
||||
</Markdown>
|
||||
)
|
||||
|
||||
expect(view.container.querySelector('img')).toBeNull()
|
||||
expect(view.container).toHaveTextContent('\\includegraphics')
|
||||
} finally {
|
||||
if (originalTrust) {
|
||||
Object.defineProperty(Object.prototype, 'trust', originalTrust)
|
||||
} else {
|
||||
Reflect.deleteProperty(Object.prototype, 'trust')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -70,7 +70,7 @@ function AlertDialogContent({
|
||||
data-slot='alert-dialog-content'
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
|
||||
'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-[var(--dialog-viewport-center,50dvh)] left-1/2 z-50 grid max-h-[var(--dialog-available-height,calc(100dvh-2rem))] w-full overflow-y-auto overscroll-contain -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -39,7 +39,7 @@ interface ComboboxInputProps {
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
className?: string
|
||||
dropdownClassName?: string
|
||||
popupClassName?: string
|
||||
id?: string
|
||||
allowCustomValue?: boolean
|
||||
openOnFocus?: boolean
|
||||
@@ -57,7 +57,7 @@ export function ComboboxInput({
|
||||
placeholder = 'Select or type...',
|
||||
emptyText = 'No option found.',
|
||||
className,
|
||||
dropdownClassName,
|
||||
popupClassName,
|
||||
id,
|
||||
allowCustomValue = false,
|
||||
openOnFocus = true,
|
||||
@@ -297,7 +297,7 @@ export function ComboboxInput({
|
||||
}}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-100 rounded-md border shadow-md',
|
||||
dropdownClassName
|
||||
popupClassName
|
||||
)}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ type LegacyComboboxProps = {
|
||||
allowCustomValue?: boolean
|
||||
showSelectedIcon?: boolean
|
||||
className?: string
|
||||
dropdownClassName?: string
|
||||
popupClassName?: string
|
||||
id?: string
|
||||
openOnFocus?: boolean
|
||||
disabled?: boolean
|
||||
@@ -88,7 +88,7 @@ function Combobox(
|
||||
placeholder={props.searchPlaceholder ?? props.placeholder}
|
||||
emptyText={props.emptyText}
|
||||
className={props.className}
|
||||
dropdownClassName={props.dropdownClassName}
|
||||
popupClassName={props.popupClassName}
|
||||
allowCustomValue={props.allowCustomValue}
|
||||
openOnFocus={props.openOnFocus}
|
||||
/>
|
||||
@@ -160,7 +160,7 @@ function OptionCombobox(props: LegacyComboboxProps) {
|
||||
)}
|
||||
</ComboboxInput>
|
||||
</div>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxContent anchor={anchor} className={props.popupClassName}>
|
||||
<ComboboxEmpty>
|
||||
{props.emptyText ?? t('No results found')}
|
||||
</ComboboxEmpty>
|
||||
|
||||
@@ -70,7 +70,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot='dialog-content'
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-[var(--dialog-viewport-center,50dvh)] left-1/2 z-50 grid max-h-[var(--dialog-available-height,calc(100dvh-2rem))] w-full overflow-y-auto overscroll-contain max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { THEME_STORAGE_KEYS } from '@/lib/theme-storage'
|
||||
|
||||
// The inline script in index.html must apply the theme class before React
|
||||
// mounts; Dark Reader checks `html.dark` only once while the page loads.
|
||||
const html = readFileSync(
|
||||
resolve(import.meta.dirname, '../../../index.html'),
|
||||
'utf8'
|
||||
)
|
||||
const bootScript = html.match(/<script>([\s\S]*?)<\/script>/)?.[1] ?? ''
|
||||
|
||||
function runBootScript() {
|
||||
new Function(bootScript)()
|
||||
}
|
||||
|
||||
function mockSystemDarkMode(dark: boolean) {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
matches: dark && query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
}) as MediaQueryList
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
})
|
||||
|
||||
describe('index.html theme boot script', () => {
|
||||
it('applies the saved dark preference to html before React mounts', () => {
|
||||
mockSystemDarkMode(false)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'dark')
|
||||
|
||||
runBootScript()
|
||||
|
||||
expect(document.documentElement).toHaveClass('dark')
|
||||
expect(document.documentElement).not.toHaveClass('light')
|
||||
})
|
||||
|
||||
it('applies the saved light preference even when the system is dark', () => {
|
||||
mockSystemDarkMode(true)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'light')
|
||||
|
||||
runBootScript()
|
||||
|
||||
expect(document.documentElement).toHaveClass('light')
|
||||
expect(document.documentElement).not.toHaveClass('dark')
|
||||
})
|
||||
|
||||
it('follows the system dark mode when no preference is saved', () => {
|
||||
mockSystemDarkMode(true)
|
||||
|
||||
runBootScript()
|
||||
|
||||
expect(document.documentElement).toHaveClass('dark')
|
||||
})
|
||||
|
||||
it('follows the system light mode when the saved value is invalid', () => {
|
||||
mockSystemDarkMode(false)
|
||||
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'sepia')
|
||||
|
||||
runBootScript()
|
||||
|
||||
expect(document.documentElement).toHaveClass('light')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
ThemeCustomizationProvider,
|
||||
useThemeCustomization,
|
||||
} from '@/context/theme-customization-provider'
|
||||
import { ThemeProvider, useTheme } from '@/context/theme-provider'
|
||||
import { initializeFrontendCache } from '@/lib/frontend-cache'
|
||||
|
||||
const savedPreferences = {
|
||||
'newapi:theme:v1:mode': 'dark',
|
||||
'newapi:theme:v1:preset': 'rose-garden',
|
||||
'newapi:theme:v1:font': 'serif',
|
||||
'newapi:theme:v1:radius': 'lg',
|
||||
'newapi:theme:v1:scale': 'sm',
|
||||
'newapi:theme:v1:content-layout': 'centered',
|
||||
}
|
||||
|
||||
function ThemeControls() {
|
||||
const theme = useTheme()
|
||||
const customization = useThemeCustomization()
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label='Theme mode'>{theme.theme}</output>
|
||||
<button type='button' onClick={() => customization.setFont('sans')}>
|
||||
Sans font
|
||||
</button>
|
||||
<button type='button' onClick={() => customization.setFont('default')}>
|
||||
Auto font
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => customization.setPreset('anthropic')}
|
||||
>
|
||||
Anthropic preset
|
||||
</button>
|
||||
<button type='button' onClick={() => customization.setPreset('default')}>
|
||||
Default preset
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
theme.setTheme('dark')
|
||||
customization.setPreset('rose-garden')
|
||||
customization.setFont('serif')
|
||||
customization.setRadius('lg')
|
||||
customization.setScale('sm')
|
||||
customization.setContentLayout('centered')
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
theme.resetTheme()
|
||||
customization.resetCustomization()
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeFixture() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ThemeCustomizationProvider>
|
||||
<ThemeControls />
|
||||
</ThemeCustomizationProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
for (const cookie of document.cookie.split(';')) {
|
||||
const name = cookie.trim().split('=')[0]
|
||||
document.cookie = `${name}=; path=/; max-age=0`
|
||||
}
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
for (const name of document.body.getAttributeNames()) {
|
||||
if (name.startsWith('data-theme-')) document.body.removeAttribute(name)
|
||||
}
|
||||
})
|
||||
|
||||
describe('theme preference persistence', () => {
|
||||
it('starts with defaults when only shared legacy theme cookies exist', () => {
|
||||
document.cookie = 'theme_preset=ocean-breeze; path=/'
|
||||
document.cookie = 'vite-ui-theme=dark; path=/'
|
||||
document.cookie = 'theme_font=serif; path=/'
|
||||
document.cookie = 'theme_radius=xl; path=/'
|
||||
document.cookie = 'theme_scale=lg; path=/'
|
||||
document.cookie = 'theme_content_layout=centered; path=/'
|
||||
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
|
||||
expect(document.documentElement).toHaveClass('light')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-preset')
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-radius')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-scale')
|
||||
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
|
||||
})
|
||||
|
||||
it('restores all customized preferences after remounting without writing cookies', async () => {
|
||||
const user = userEvent.setup()
|
||||
const first = render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
first.unmount()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
|
||||
expect(document.documentElement).toHaveClass('dark')
|
||||
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'serif')
|
||||
expect(document.body).toHaveAttribute('data-theme-radius', 'lg')
|
||||
expect(document.body).toHaveAttribute('data-theme-scale', 'sm')
|
||||
expect(document.body).toHaveAttribute(
|
||||
'data-theme-content-layout',
|
||||
'centered'
|
||||
)
|
||||
for (const [key, value] of Object.entries(savedPreferences)) {
|
||||
expect(localStorage.getItem(key)).toBe(value)
|
||||
}
|
||||
expect(document.cookie).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a reset after remounting even when the legacy ocean cookie remains', async () => {
|
||||
for (const [key, value] of Object.entries(savedPreferences)) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
localStorage.setItem('unrelated-preference', 'keep')
|
||||
document.cookie = 'theme_preset=ocean-breeze; path=/'
|
||||
const user = userEvent.setup()
|
||||
const first = render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
first.unmount()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
|
||||
expect(document.documentElement).toHaveClass('light')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-preset')
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-radius')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-scale')
|
||||
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
|
||||
for (const key of Object.keys(savedPreferences)) {
|
||||
expect(localStorage.getItem(key)).toBeNull()
|
||||
}
|
||||
expect(localStorage.getItem('unrelated-preference')).toBe('keep')
|
||||
expect(document.cookie).toContain('theme_preset=ocean-breeze')
|
||||
})
|
||||
|
||||
it.each(['', 'unknown'])(
|
||||
'uses defaults when stored preferences are %j',
|
||||
(value) => {
|
||||
for (const key of Object.keys(savedPreferences)) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-preset')
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-radius')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-scale')
|
||||
expect(document.body).toHaveAttribute('data-theme-content-layout', 'full')
|
||||
}
|
||||
)
|
||||
|
||||
it('renders defaults when reading local storage fails', () => {
|
||||
vi.spyOn(localStorage, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage unavailable', 'SecurityError')
|
||||
})
|
||||
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-preset')
|
||||
})
|
||||
|
||||
it('still applies and resets preferences when storage writes fail', async () => {
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage full', 'QuotaExceededError')
|
||||
})
|
||||
vi.spyOn(localStorage, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage unavailable', 'SecurityError')
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
|
||||
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('system')
|
||||
expect(document.body).not.toHaveAttribute('data-theme-preset')
|
||||
})
|
||||
|
||||
it('preserves saved theme preferences during frontend cache initialization', () => {
|
||||
for (const [key, value] of Object.entries(savedPreferences)) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
localStorage.setItem('stale-ui-cache', 'old')
|
||||
|
||||
initializeFrontendCache()
|
||||
initializeFrontendCache()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(screen.getByLabelText('Theme mode')).toHaveTextContent('dark')
|
||||
expect(document.body).toHaveAttribute('data-theme-preset', 'rose-garden')
|
||||
for (const [key, value] of Object.entries(savedPreferences)) {
|
||||
expect(localStorage.getItem(key)).toBe(value)
|
||||
}
|
||||
expect(localStorage.getItem('stale-ui-cache')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme font selection', () => {
|
||||
it('keeps manually selected Sans after remounting and switching presets', async () => {
|
||||
const user = userEvent.setup()
|
||||
const first = render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sans font' }))
|
||||
first.unmount()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'sans')
|
||||
expect(localStorage.getItem('newapi:theme:v1:font')).toBe('sans')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic preset' }))
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'sans')
|
||||
})
|
||||
|
||||
it('restores the browser font after selecting Auto and remounting', async () => {
|
||||
localStorage.setItem('newapi:theme:v1:font', 'sans')
|
||||
const user = userEvent.setup()
|
||||
const first = render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Auto font' }))
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
expect(localStorage.getItem('newapi:theme:v1:font')).toBeNull()
|
||||
|
||||
first.unmount()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
})
|
||||
|
||||
it('uses the preset font in Auto and restores the browser font on the default preset', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ThemeFixture />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic preset' }))
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'serif')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Default preset' }))
|
||||
|
||||
expect(document.body).toHaveAttribute('data-theme-font', 'system')
|
||||
expect(localStorage.getItem('newapi:theme:v1:font')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -18,8 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
|
||||
type SearchContextType = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
@@ -48,7 +46,6 @@ export function SearchProvider({ children }: SearchProviderProps) {
|
||||
return (
|
||||
<SearchContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
<CommandMenu />
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,13 +25,11 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { getCookie, removeCookie, setCookie } from '@/lib/cookies'
|
||||
import {
|
||||
CONTENT_LAYOUT_VALUES,
|
||||
type ContentLayout,
|
||||
DEFAULT_THEME_CUSTOMIZATION,
|
||||
resolveThemeFont,
|
||||
THEME_COOKIE_KEYS,
|
||||
THEME_FONT_VALUES,
|
||||
THEME_PRESET_VALUES,
|
||||
THEME_RADIUS_VALUES,
|
||||
@@ -42,17 +40,11 @@ import {
|
||||
type ThemeRadius,
|
||||
type ThemeScale,
|
||||
} from '@/lib/theme-customization'
|
||||
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
function readCookie<T extends string>(
|
||||
name: string,
|
||||
allowed: ReadonlySet<T>,
|
||||
fallback: T
|
||||
): T {
|
||||
const value = getCookie(name)
|
||||
return value && allowed.has(value as T) ? (value as T) : fallback
|
||||
}
|
||||
import {
|
||||
readThemePreference,
|
||||
THEME_STORAGE_KEYS,
|
||||
writeThemePreference,
|
||||
} from '@/lib/theme-storage'
|
||||
|
||||
function applyAttribute(name: string, value: string | null) {
|
||||
if (typeof document === 'undefined') return
|
||||
@@ -98,36 +90,36 @@ export function ThemeCustomizationProvider(props: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [preset, _setPreset] = useState<ThemePreset>(() =>
|
||||
readCookie<ThemePreset>(
|
||||
THEME_COOKIE_KEYS.preset,
|
||||
readThemePreference<ThemePreset>(
|
||||
THEME_STORAGE_KEYS.preset,
|
||||
THEME_PRESET_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.preset
|
||||
)
|
||||
)
|
||||
const [font, _setFont] = useState<ThemeFont>(() =>
|
||||
readCookie<ThemeFont>(
|
||||
THEME_COOKIE_KEYS.font,
|
||||
readThemePreference<ThemeFont>(
|
||||
THEME_STORAGE_KEYS.font,
|
||||
THEME_FONT_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.font
|
||||
)
|
||||
)
|
||||
const [radius, _setRadius] = useState<ThemeRadius>(() =>
|
||||
readCookie<ThemeRadius>(
|
||||
THEME_COOKIE_KEYS.radius,
|
||||
readThemePreference<ThemeRadius>(
|
||||
THEME_STORAGE_KEYS.radius,
|
||||
THEME_RADIUS_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.radius
|
||||
)
|
||||
)
|
||||
const [scale, _setScale] = useState<ThemeScale>(() =>
|
||||
readCookie<ThemeScale>(
|
||||
THEME_COOKIE_KEYS.scale,
|
||||
readThemePreference<ThemeScale>(
|
||||
THEME_STORAGE_KEYS.scale,
|
||||
THEME_SCALE_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.scale
|
||||
)
|
||||
)
|
||||
const [contentLayout, _setContentLayout] = useState<ContentLayout>(() =>
|
||||
readCookie<ContentLayout>(
|
||||
THEME_COOKIE_KEYS.contentLayout,
|
||||
readThemePreference<ContentLayout>(
|
||||
THEME_STORAGE_KEYS.contentLayout,
|
||||
CONTENT_LAYOUT_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.contentLayout
|
||||
)
|
||||
@@ -142,12 +134,8 @@ export function ThemeCustomizationProvider(props: {
|
||||
)
|
||||
}, [preset])
|
||||
|
||||
// Font is the one axis where we resolve before writing the attribute:
|
||||
// the persisted preference may be `default`, but CSS works in terms of
|
||||
// the concrete `sans`/`serif` choice that should drive the cascade.
|
||||
// Resolving here (instead of in CSS via `:not()` selectors) keeps the
|
||||
// stylesheet to one simple `[data-theme-font='serif']` selector and lets
|
||||
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
|
||||
// Resolve Auto into the preset font or the browser's sans-serif while
|
||||
// keeping an explicit Sans choice distinct from that browser fallback.
|
||||
useEffect(() => {
|
||||
applyAttribute('data-theme-font', resolveThemeFont(font, preset))
|
||||
}, [font, preset])
|
||||
@@ -172,47 +160,42 @@ export function ThemeCustomizationProvider(props: {
|
||||
|
||||
const setPreset = useCallback((value: ThemePreset) => {
|
||||
_setPreset(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.preset) {
|
||||
removeCookie(THEME_COOKIE_KEYS.preset)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.preset, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
writeThemePreference(
|
||||
THEME_STORAGE_KEYS.preset,
|
||||
value === DEFAULT_THEME_CUSTOMIZATION.preset ? null : value
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setFont = useCallback((value: ThemeFont) => {
|
||||
_setFont(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.font) {
|
||||
removeCookie(THEME_COOKIE_KEYS.font)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
writeThemePreference(
|
||||
THEME_STORAGE_KEYS.font,
|
||||
value === DEFAULT_THEME_CUSTOMIZATION.font ? null : value
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setRadius = useCallback((value: ThemeRadius) => {
|
||||
_setRadius(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.radius) {
|
||||
removeCookie(THEME_COOKIE_KEYS.radius)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.radius, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
writeThemePreference(
|
||||
THEME_STORAGE_KEYS.radius,
|
||||
value === DEFAULT_THEME_CUSTOMIZATION.radius ? null : value
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setScale = useCallback((value: ThemeScale) => {
|
||||
_setScale(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.scale) {
|
||||
removeCookie(THEME_COOKIE_KEYS.scale)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.scale, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
writeThemePreference(
|
||||
THEME_STORAGE_KEYS.scale,
|
||||
value === DEFAULT_THEME_CUSTOMIZATION.scale ? null : value
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setContentLayout = useCallback((value: ContentLayout) => {
|
||||
_setContentLayout(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.contentLayout) {
|
||||
removeCookie(THEME_COOKIE_KEYS.contentLayout)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.contentLayout, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
writeThemePreference(
|
||||
THEME_STORAGE_KEYS.contentLayout,
|
||||
value === DEFAULT_THEME_CUSTOMIZATION.contentLayout ? null : value
|
||||
)
|
||||
}, [])
|
||||
|
||||
const resetCustomization = useCallback(() => {
|
||||
|
||||
@@ -25,14 +25,16 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
import {
|
||||
readThemePreference,
|
||||
THEME_STORAGE_KEYS,
|
||||
writeThemePreference,
|
||||
} from '@/lib/theme-storage'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
type ResolvedTheme = Exclude<Theme, 'system'>
|
||||
|
||||
const DEFAULT_THEME = 'system'
|
||||
const THEME_COOKIE_NAME = 'vite-ui-theme'
|
||||
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
const THEMES = new Set<Theme>(['dark', 'light', 'system'])
|
||||
|
||||
type ThemeProviderProps = {
|
||||
@@ -70,22 +72,17 @@ function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
return theme === 'system' ? getSystemTheme() : theme
|
||||
}
|
||||
|
||||
function getStoredTheme(storageKey: string, fallback: Theme): Theme {
|
||||
const storedTheme = getCookie(storageKey) as Theme | undefined
|
||||
return storedTheme && THEMES.has(storedTheme) ? storedTheme : fallback
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = DEFAULT_THEME,
|
||||
storageKey = THEME_COOKIE_NAME,
|
||||
storageKey = THEME_STORAGE_KEYS.mode,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, _setTheme] = useState<Theme>(() =>
|
||||
getStoredTheme(storageKey, defaultTheme)
|
||||
readThemePreference(storageKey, THEMES, defaultTheme)
|
||||
)
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
|
||||
resolveTheme(getStoredTheme(storageKey, defaultTheme))
|
||||
resolveTheme(theme)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,14 +105,14 @@ export function ThemeProvider({
|
||||
|
||||
const setTheme = useCallback(
|
||||
(theme: Theme) => {
|
||||
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
|
||||
writeThemePreference(storageKey, theme)
|
||||
_setTheme(theme)
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
const resetTheme = useCallback(() => {
|
||||
removeCookie(storageKey)
|
||||
writeThemePreference(storageKey, null)
|
||||
_setTheme(defaultTheme)
|
||||
}, [defaultTheme, storageKey])
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import axios from 'axios'
|
||||
|
||||
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import {
|
||||
getServerErrorMessageKey,
|
||||
getServerMessage,
|
||||
} from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
@@ -204,7 +207,7 @@ export async function createOAuthAuthorization(
|
||||
}
|
||||
throw new AuthOperationError(
|
||||
getServerErrorMessageKey(res.data) ||
|
||||
res.data?.message ||
|
||||
getServerMessage(res.data) ||
|
||||
'Failed to initialize OAuth',
|
||||
res.data?.code
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
|
||||
|
||||
import { IconGithub, IconLinuxDo, IconWeChat } from '@/assets/brand-icons'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
@@ -37,21 +38,21 @@ const providerDictionary: Record<string, ProviderMeta> = {
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiGithub className={props.className} focusable='false' />
|
||||
<IconGithub className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
oidc: { label: 'OIDC', Icon: Shield },
|
||||
linuxdo: {
|
||||
label: 'LinuxDO',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiLinux className={props.className} focusable='false' />
|
||||
<IconLinuxDo className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
telegram: { label: 'Telegram', Icon: Send },
|
||||
wechat: {
|
||||
label: 'WeChat',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiWechat className={props.className} focusable='false' />
|
||||
<IconWeChat className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
prepareCredentialCreationOptions,
|
||||
} from '@/lib/passkey'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { getServerMessage } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
beginPasskeyRegistration,
|
||||
@@ -50,7 +51,7 @@ export function usePasskeyManagement() {
|
||||
const response = await getPasskeyStatus()
|
||||
if (!response.success || !response.data) {
|
||||
throw new AuthOperationError(
|
||||
response.message || 'Failed to load Passkey status'
|
||||
getServerMessage(response) || 'Failed to load Passkey status'
|
||||
)
|
||||
}
|
||||
if (!mounted.current) return
|
||||
|
||||
@@ -25,6 +25,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
|
||||
import { SecureVerificationDialog } from '../components/secure-verification-dialog'
|
||||
import { useSecureVerification } from '../hooks/use-secure-verification'
|
||||
import type {
|
||||
@@ -40,6 +41,25 @@ const passwordRequirements: VerificationRequirements = {
|
||||
password_encryption_enabled: false,
|
||||
}
|
||||
|
||||
const linkedAccountRequirements: VerificationRequirements = {
|
||||
scope: '2fa.setup',
|
||||
methods: [{ method: 'oauth', available: true }],
|
||||
oauth_providers: [{ slug: 'linuxdo', name: 'Linux DO' }],
|
||||
password_encryption_enabled: false,
|
||||
}
|
||||
|
||||
function stubOAuthPopup() {
|
||||
const popup = {
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
sessionStorage: window.sessionStorage,
|
||||
close: vi.fn(),
|
||||
postMessage: vi.fn(),
|
||||
}
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
|
||||
return popup
|
||||
}
|
||||
|
||||
function Harness(props: {
|
||||
onResult: (proof: SecurityProof | null) => void
|
||||
operation?: RequestVerificationOptions
|
||||
@@ -80,6 +100,7 @@ function pendingResponse<T>() {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
function LoginHarness(props: {
|
||||
@@ -428,3 +449,123 @@ it('keeps an enrolled Passkey unavailable when this browser cannot use it', asyn
|
||||
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
|
||||
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts linked-account verification from the provider button without a separate Verify button', async () => {
|
||||
const popup = stubOAuthPopup()
|
||||
const proof: SecurityProof = {
|
||||
proof_token: 'oauth-proof',
|
||||
method: 'oauth',
|
||||
scope: '2fa.setup',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
}
|
||||
vi.spyOn(api, 'get').mockImplementation((url) => {
|
||||
if (url === '/api/verify/methods') {
|
||||
return Promise.resolve({
|
||||
data: { success: true, data: linkedAccountRequirements },
|
||||
})
|
||||
}
|
||||
if (url === '/api/oauth/linuxdo') {
|
||||
return Promise.resolve({ data: { success: true, data: proof } })
|
||||
}
|
||||
return Promise.resolve({ data: { success: true, data: {} } })
|
||||
})
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
flow_token: 'verification-state',
|
||||
authorization_url: 'https://connect.linux.do/oauth2/authorize?s=1',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness operation={{ scope: '2fa.setup' }} onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
const providerButton = await screen.findByRole('button', {
|
||||
name: 'Continue with Linux DO',
|
||||
})
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Verify' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
await user.click(providerButton)
|
||||
expect(window.open).toHaveBeenCalledTimes(1)
|
||||
expect(providerButton).toBeDisabled()
|
||||
await waitFor(() =>
|
||||
expect(popup.location.replace).toHaveBeenCalledWith(
|
||||
'https://connect.linux.do/oauth2/authorize?s=1'
|
||||
)
|
||||
)
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(
|
||||
'/api/oauth/state',
|
||||
expect.objectContaining({
|
||||
provider: 'linuxdo',
|
||||
intent: 'verify',
|
||||
scope: '2fa.setup',
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
const event = new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
data: {
|
||||
type: OAUTH_POPUP_CALLBACK_MESSAGE,
|
||||
intent: 'verify',
|
||||
provider: 'linuxdo',
|
||||
state: 'verification-state',
|
||||
code: 'code',
|
||||
},
|
||||
})
|
||||
Object.defineProperty(event, 'source', { value: popup })
|
||||
window.dispatchEvent(event)
|
||||
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(proof))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('verifies with the clicked provider when several linked accounts are available', async () => {
|
||||
const popup = stubOAuthPopup()
|
||||
vi.spyOn(api, 'get').mockImplementation((url) =>
|
||||
url === '/api/verify/methods'
|
||||
? Promise.resolve({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
...linkedAccountRequirements,
|
||||
oauth_providers: [
|
||||
{ slug: 'linuxdo', name: 'Linux DO' },
|
||||
{ slug: 'github', name: 'GitHub' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve({ data: { success: true, data: {} } })
|
||||
)
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
flow_token: 'github-state',
|
||||
authorization_url: 'https://github.com/login/oauth/authorize?s=1',
|
||||
},
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<Harness operation={{ scope: '2fa.setup' }} onResult={vi.fn()} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Continue with GitHub' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(popup.location.replace).toHaveBeenCalledWith(
|
||||
'https://github.com/login/oauth/authorize?s=1'
|
||||
)
|
||||
)
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(
|
||||
'/api/oauth/state',
|
||||
expect.objectContaining({ provider: 'github' }),
|
||||
expect.anything()
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Continue with Linux DO' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
+13
-18
@@ -37,7 +37,7 @@ import type {
|
||||
interface SecureVerificationDialogProps {
|
||||
state: SecureVerificationState
|
||||
passkeyDomains?: PasskeyDomains | null
|
||||
onVerify: () => void | Promise<void>
|
||||
onVerify: (input?: VerificationInput) => void | Promise<void>
|
||||
onCancel: () => void
|
||||
onRetry: () => void
|
||||
onInputChange: (input: VerificationInput) => void
|
||||
@@ -70,9 +70,8 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
|
||||
if (input?.method === '2fa') {
|
||||
canVerify = canVerify && input.code.trim().length >= 6
|
||||
}
|
||||
if (input?.method === 'oauth') {
|
||||
canVerify = canVerify && Boolean(input.provider)
|
||||
}
|
||||
// Linked-account verification starts from the provider button itself.
|
||||
const showSubmit = state.phase !== 'error' && input?.method !== 'oauth'
|
||||
const error = 'error' in state ? state.error : undefined
|
||||
const formId = `${inputId}-form`
|
||||
|
||||
@@ -123,11 +122,12 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
|
||||
<Button type='button' variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
{state.phase === 'error' ? (
|
||||
{state.phase === 'error' && (
|
||||
<Button type='button' onClick={props.onRetry}>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
) : (
|
||||
)}
|
||||
{showSubmit && (
|
||||
<Button type='submit' form={formId} disabled={!canVerify}>
|
||||
{verifying && <Loader2 className='size-4 animate-spin' />}
|
||||
{t('Verify')}
|
||||
@@ -253,25 +253,20 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
|
||||
<Button
|
||||
key={provider.slug}
|
||||
type='button'
|
||||
variant={
|
||||
input.method === 'oauth' &&
|
||||
input.provider === provider.slug
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
aria-pressed={
|
||||
input.method === 'oauth' &&
|
||||
input.provider === provider.slug
|
||||
}
|
||||
disabled={verifying}
|
||||
onClick={() =>
|
||||
props.onInputChange({
|
||||
void props.onVerify({
|
||||
method: 'oauth',
|
||||
provider: provider.slug,
|
||||
})
|
||||
}
|
||||
>
|
||||
{provider.name}
|
||||
{verifying &&
|
||||
input.method === 'oauth' &&
|
||||
input.provider === provider.slug && (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
)}
|
||||
{t('Continue with {{name}}', { name: provider.name })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -277,58 +277,60 @@ export function useSecureVerification() {
|
||||
[loadRequirements]
|
||||
)
|
||||
|
||||
const executeVerification = useCallback(async () => {
|
||||
const current = pending.current
|
||||
if (
|
||||
!current ||
|
||||
current.submitting ||
|
||||
state.phase !== 'ready' ||
|
||||
!state.input
|
||||
) {
|
||||
return
|
||||
}
|
||||
current.submitting = true
|
||||
const input = state.input
|
||||
dispatch({ type: 'submit' })
|
||||
try {
|
||||
if (current.kind === 'login') {
|
||||
const bundle = await verifyLogin(
|
||||
input,
|
||||
current.request.challenge,
|
||||
current.controller.signal,
|
||||
(domains) => {
|
||||
if (pending.current === current) setPasskeyDomains(domains)
|
||||
}
|
||||
)
|
||||
const executeVerification = useCallback(
|
||||
async (override?: VerificationInput) => {
|
||||
const current = pending.current
|
||||
if (!current || current.submitting || state.phase !== 'ready') return
|
||||
const input = override ?? state.input
|
||||
if (!input) return
|
||||
const available = state.requirements.methods.some(
|
||||
(option) => option.method === input.method && option.available
|
||||
)
|
||||
if (!available) return
|
||||
current.submitting = true
|
||||
if (override) dispatch({ type: 'input', input })
|
||||
dispatch({ type: 'submit' })
|
||||
try {
|
||||
if (current.kind === 'login') {
|
||||
const bundle = await verifyLogin(
|
||||
input,
|
||||
current.request.challenge,
|
||||
current.controller.signal,
|
||||
(domains) => {
|
||||
if (pending.current === current) setPasskeyDomains(domains)
|
||||
}
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
current.resolve(bundle)
|
||||
} else {
|
||||
const proof = await verify(
|
||||
input,
|
||||
current.request,
|
||||
state.requirements.password_encryption_enabled,
|
||||
current.controller.signal,
|
||||
(domains) => {
|
||||
if (pending.current === current) setPasskeyDomains(domains)
|
||||
}
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
current.resolve(proof)
|
||||
}
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
} catch (error) {
|
||||
if (pending.current !== current) return
|
||||
current.resolve(bundle)
|
||||
} else {
|
||||
const proof = await verify(
|
||||
input,
|
||||
current.request,
|
||||
state.requirements.password_encryption_enabled,
|
||||
current.controller.signal,
|
||||
(domains) => {
|
||||
if (pending.current === current) setPasskeyDomains(domains)
|
||||
}
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
current.resolve(proof)
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code === 'AUTH_CANCELLED') {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'error', error: failure.message })
|
||||
} finally {
|
||||
current.submitting = false
|
||||
}
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
} catch (error) {
|
||||
if (pending.current !== current) return
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code === 'AUTH_CANCELLED') {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'error', error: failure.message })
|
||||
} finally {
|
||||
current.submitting = false
|
||||
}
|
||||
}, [cancel, state])
|
||||
},
|
||||
[cancel, state]
|
||||
)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (pending.current && state.phase === 'error') {
|
||||
|
||||
@@ -32,12 +32,23 @@ export type SecurityProofScope =
|
||||
| '2fa.disable'
|
||||
| '2fa.backup_codes.regenerate'
|
||||
| 'access_token.generate'
|
||||
| 'access_token.update'
|
||||
| 'access_token.revoke'
|
||||
| 'account.binding.bind'
|
||||
| 'account.binding.unbind'
|
||||
| 'account.password.set'
|
||||
| 'account.password.change'
|
||||
| 'account.delete'
|
||||
| 'admin.user.create'
|
||||
| 'admin.user.update'
|
||||
| 'admin.user.delete'
|
||||
| 'admin.user.manage'
|
||||
| 'admin.user.passkey.reset'
|
||||
| 'admin.user.2fa.disable'
|
||||
| 'admin.user.binding.clear'
|
||||
|
||||
/** ManageUser actions that change a user's status or role and need step-up. */
|
||||
export type AdminUserManageAction = 'disable' | 'enable' | 'promote' | 'demote'
|
||||
|
||||
export type VerificationOperation =
|
||||
| { scope: 'channel.key.read'; context: { channel_id: number } }
|
||||
@@ -46,10 +57,47 @@ export type VerificationOperation =
|
||||
context: { provider: string; email?: string; code?: string }
|
||||
}
|
||||
| { scope: 'account.binding.unbind'; context: { provider_id: number } }
|
||||
| { scope: 'admin.user.create'; context: { role: number } }
|
||||
| {
|
||||
scope:
|
||||
| 'admin.user.update'
|
||||
| 'admin.user.delete'
|
||||
| 'admin.user.passkey.reset'
|
||||
| 'admin.user.2fa.disable'
|
||||
context: { user_id: number }
|
||||
}
|
||||
| {
|
||||
scope: 'admin.user.manage'
|
||||
context: { user_id: number; action: AdminUserManageAction }
|
||||
}
|
||||
| {
|
||||
scope: 'admin.user.binding.clear'
|
||||
context:
|
||||
| { user_id: number; binding_type: string }
|
||||
| { user_id: number; provider_id: number }
|
||||
}
|
||||
| {
|
||||
scope: 'access_token.generate'
|
||||
context: { scopes: string[]; expires_at: number }
|
||||
}
|
||||
| {
|
||||
scope: 'access_token.update'
|
||||
context: { token_id: number; scopes: string[] }
|
||||
}
|
||||
| {
|
||||
scope: 'access_token.revoke'
|
||||
context: { token_id: number } | { legacy: true }
|
||||
}
|
||||
| {
|
||||
scope: Exclude<
|
||||
SecurityProofScope,
|
||||
'channel.key.read' | 'account.binding.bind' | 'account.binding.unbind'
|
||||
| 'channel.key.read'
|
||||
| 'account.binding.bind'
|
||||
| 'account.binding.unbind'
|
||||
| 'access_token.generate'
|
||||
| 'access_token.update'
|
||||
| 'access_token.revoke'
|
||||
| `admin.user.${string}`
|
||||
>
|
||||
context?: Record<string, never>
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ export function UserAuthForm({
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
|
||||
@@ -182,6 +182,11 @@ export interface SystemStatus {
|
||||
password_login_encryption_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
log_export_enabled?: boolean
|
||||
log_max_range_days?: number
|
||||
quota_remind_enabled?: boolean
|
||||
/** Site default low balance reminder threshold, in quota units */
|
||||
quota_remind_threshold?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getGroups as getUserGroups } from '@/features/users/api'
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
|
||||
import type { PriorityMove, PriorityOrder } from './lib/channel-priority-order'
|
||||
import type { InferenceStatus } from './lib/inference-status'
|
||||
import type {
|
||||
AddChannelRequest,
|
||||
@@ -50,6 +51,24 @@ const channelActionConfig = (
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
|
||||
export async function getChannelPriorityOrder(): Promise<PriorityOrder> {
|
||||
const response = await api.get<{ success: boolean; data: PriorityOrder }>(
|
||||
'/api/channel/priority/order',
|
||||
{ disableDuplicate: true }
|
||||
)
|
||||
return requireServerSuccess(response.data).data
|
||||
}
|
||||
|
||||
export async function reorderChannelPriority(
|
||||
move: PriorityMove
|
||||
): Promise<PriorityOrder> {
|
||||
const response = await api.post<{ success: boolean; data: PriorityOrder }>(
|
||||
'/api/channel/priority/reorder',
|
||||
move
|
||||
)
|
||||
return requireServerSuccess(response.data).data
|
||||
}
|
||||
|
||||
export async function getInferenceStatus(
|
||||
channelId: number,
|
||||
provider: 'vllm' | 'sglang',
|
||||
@@ -73,6 +92,7 @@ export type TaskPluginOption = {
|
||||
baseUrl?: string
|
||||
models: string[]
|
||||
channelTypes?: number[] | null
|
||||
upstreams?: string[] | null
|
||||
}
|
||||
|
||||
export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
|
||||
|
||||
@@ -63,6 +63,7 @@ const plugins: TaskPluginOption[] = [
|
||||
icon: 'text:VA',
|
||||
baseUrl: 'https://a.example',
|
||||
models: ['video-a-1'],
|
||||
upstreams: ['vendor', 'new_api'],
|
||||
},
|
||||
{
|
||||
key: 'video-b',
|
||||
@@ -70,8 +71,10 @@ const plugins: TaskPluginOption[] = [
|
||||
icon: 'text:VB',
|
||||
baseUrl: 'https://b.example',
|
||||
models: ['video-b-1'],
|
||||
upstreams: ['vendor', 'new_api'],
|
||||
},
|
||||
{
|
||||
// Vendor-only: bindable to a Task Plugin channel, never to a New API one.
|
||||
key: 'no-address',
|
||||
name: 'No Address',
|
||||
icon: 'text:NA',
|
||||
@@ -1320,7 +1323,7 @@ test('editing legacy channels retains the full provider list and saves the origi
|
||||
soraPlugin,
|
||||
{
|
||||
key: 'doubao',
|
||||
name: 'Doubao Video',
|
||||
name: 'Doubao',
|
||||
icon: 'text',
|
||||
models: ['doubao-video'],
|
||||
},
|
||||
@@ -1336,7 +1339,7 @@ test('editing legacy channels retains the full provider list and saves the origi
|
||||
await screen.findByRole('option', { name: 'Sora Plugin sora' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'DoubaoVideo Built-in #54' })
|
||||
screen.getByRole('option', { name: 'Doubao Built-in #54' })
|
||||
).toBeVisible()
|
||||
const legacy = screen.getByRole('option', { name: 'Sora Built-in #55' })
|
||||
expect(legacy).toHaveAttribute('aria-current', 'true')
|
||||
@@ -1608,6 +1611,56 @@ test('an Ollama channel marks a saved OpenAI-compatible chat setting in Request
|
||||
})
|
||||
})
|
||||
|
||||
test('a saved upstream TLS verification skip marks Other Settings and turning it off saves the setting without it', async () => {
|
||||
editingChannel = {
|
||||
...editingChannel,
|
||||
setting: '{"tls_insecure_skip_verify":true}',
|
||||
}
|
||||
const put = vi
|
||||
.spyOn(api, 'put')
|
||||
.mockResolvedValue({ data: { success: true } })
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
const otherTab = screen.getByRole('tab', { name: /Other Settings/ })
|
||||
expect(otherTab).toHaveAccessibleName(/Configured/)
|
||||
await user.click(otherTab)
|
||||
const toggle = screen.getByRole('switch', {
|
||||
name: 'Skip Upstream TLS Verification',
|
||||
})
|
||||
expect(toggle).toBeChecked()
|
||||
await user.click(toggle)
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(otherTab).not.toHaveAccessibleName(/Configured/)
|
||||
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalled())
|
||||
const payload = put.mock.calls[0]?.[1] as { setting: string }
|
||||
expect(JSON.parse(payload.setting)).not.toHaveProperty(
|
||||
'tls_insecure_skip_verify'
|
||||
)
|
||||
})
|
||||
|
||||
test('turning on the upstream TLS verification skip saves it in the channel setting', async () => {
|
||||
const put = vi
|
||||
.spyOn(api, 'put')
|
||||
.mockResolvedValue({ data: { success: true } })
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('tab', { name: /Other Settings/ }))
|
||||
const toggle = screen.getByRole('switch', {
|
||||
name: 'Skip Upstream TLS Verification',
|
||||
})
|
||||
expect(toggle).not.toBeChecked()
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalled())
|
||||
const payload = put.mock.calls[0]?.[1] as { setting: string }
|
||||
expect(JSON.parse(payload.setting)).toMatchObject({
|
||||
tls_insecure_skip_verify: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('an invalid edit switches categories and replaces configured styling with the field error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
@@ -2301,10 +2354,14 @@ test('redirecting a model from the model picker publishes it, adds a mapping row
|
||||
})
|
||||
|
||||
test('fetched upstream models can be redirected in bulk with the model list synced', async () => {
|
||||
editingChannel.models = 'custom-model,upstream-model'
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('tab', { name: 'Existing Models (1)' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'upstream-model' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Batch redirect' }))
|
||||
@@ -2314,6 +2371,9 @@ test('fetched upstream models can be redirected in bulk with the model list sync
|
||||
expect(
|
||||
dialog.getByRole('tab', { name: /Upstream model list/ })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
dialog.getByRole('checkbox', { name: 'upstream-model' })
|
||||
).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'upstream-model' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-model')
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
@@ -2420,9 +2480,127 @@ test('on wide screens redirecting a fetched model opens the floating panel besid
|
||||
).toHaveValue('upstream')
|
||||
})
|
||||
|
||||
test('a suggested rule in the floating panel maps every matching fetched model and publishes the derived names', async () => {
|
||||
test.each(['current session', 'saved preference'])(
|
||||
'quick redirect expands a panel collapsed in the %s and focuses the new request name',
|
||||
async (collapsedIn) => {
|
||||
useWideScreen()
|
||||
editingChannel.model_mapping = '{"existing-alias":"existing-upstream"}'
|
||||
if (collapsedIn === 'saved preference') {
|
||||
window.localStorage.setItem(
|
||||
'floating-window:channel-model-redirects',
|
||||
JSON.stringify({
|
||||
x: 1000,
|
||||
y: 80,
|
||||
width: 380,
|
||||
height: 360,
|
||||
collapsed: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Fetch from Upstream' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'upstream-model' })
|
||||
if (collapsedIn === 'current session') {
|
||||
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(screen.getByRole('button', { name: 'Expand panel' })).toBeVisible()
|
||||
}
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Redirect upstream-model' })
|
||||
)
|
||||
let panelElement = await screen.findByRole('complementary', {
|
||||
name: 'Model redirects',
|
||||
})
|
||||
let panel = within(panelElement)
|
||||
await waitFor(() =>
|
||||
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
|
||||
)
|
||||
const request = panel.getAllByRole('combobox', {
|
||||
name: 'Request Model Name',
|
||||
})[1]
|
||||
await waitFor(() => expect(request).toHaveFocus())
|
||||
expect(request).toHaveValue('')
|
||||
expect(panel.getByDisplayValue('existing-alias')).toBeVisible()
|
||||
expect(panel.getByDisplayValue('existing-upstream')).toBeVisible()
|
||||
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
|
||||
if (collapsedIn === 'saved preference') {
|
||||
expect(panelElement).toHaveStyle({
|
||||
left: '1000px',
|
||||
top: '80px',
|
||||
width: '380px',
|
||||
height: '360px',
|
||||
})
|
||||
}
|
||||
|
||||
// Handling the draft must not prevent collapse or swallow the next shortcut for the same model.
|
||||
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Model redirects' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Redirect upstream-model' })
|
||||
)
|
||||
panelElement = await screen.findByRole('complementary', {
|
||||
name: 'Model redirects',
|
||||
})
|
||||
panel = within(panelElement)
|
||||
await waitFor(() =>
|
||||
expect(panel.getByDisplayValue('upstream-model')).toBeVisible()
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
panel.getAllByRole('combobox', { name: 'Request Model Name' })[1]
|
||||
).toHaveFocus()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('redirecting a recognized upstream name opens an empty alias draft without changing the model list', async () => {
|
||||
useWideScreen()
|
||||
const baseGet = vi.mocked(api.get).getMockImplementation()
|
||||
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
|
||||
if (url === '/api/channel/fetch_models/42') {
|
||||
return { data: { success: true, data: ['gpt-4o-all'] } }
|
||||
}
|
||||
if (url === '/api/channel/models') {
|
||||
return { data: { success: true, data: [{ id: 'gpt-4o' }] } }
|
||||
}
|
||||
return baseGet?.(url, ...rest)
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Redirect gpt-4o-all' })
|
||||
)
|
||||
const panel = within(
|
||||
await screen.findByRole('complementary', { name: 'Model redirects' })
|
||||
)
|
||||
const request = panel.getByRole('combobox', { name: 'Request Model Name' })
|
||||
await waitFor(() => expect(request).toHaveFocus())
|
||||
expect(request).toHaveValue('')
|
||||
expect(
|
||||
panel.getByRole('combobox', { name: 'Upstream Model Name' })
|
||||
).toHaveValue('gpt-4o-all')
|
||||
expect(screen.queryByLabelText('Published as gpt-4o')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('group', { name: 'Models' })).getByRole('button', {
|
||||
name: 'custom-model',
|
||||
})
|
||||
).toBeVisible()
|
||||
expect(screen.queryByText('1 model(s) redirected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the redirect panel batch action starts empty and only applies explicitly selected models after preview', async () => {
|
||||
useWideScreen()
|
||||
editingChannel.models = 'custom-model,gpt-4o-all,o3-all'
|
||||
const baseGet = vi.mocked(api.get).getMockImplementation()
|
||||
vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => {
|
||||
if (url === '/api/channel/fetch_models/42') {
|
||||
return { data: { success: true, data: ['gpt-4o-all', 'o3-all'] } }
|
||||
@@ -2436,22 +2614,47 @@ test('a suggested rule in the floating panel maps every matching fetched model a
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
await screen.findByDisplayValue('Existing channel')
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' }))
|
||||
await user.click(
|
||||
await screen.findByRole('tab', { name: 'Existing Models (2)' })
|
||||
)
|
||||
await screen.findByRole('checkbox', { name: 'gpt-4o-all' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Redirect panel' }))
|
||||
const panel = within(
|
||||
await screen.findByRole('complementary', { name: 'Model redirects' })
|
||||
)
|
||||
await user.click(panel.getByRole('button', { name: /Strip -all/ }))
|
||||
expect(panel.queryByText('Suggested rules')).not.toBeInTheDocument()
|
||||
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
|
||||
let dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
expect(dialog.getByRole('checkbox', { name: 'o3-all' })).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(dialog.getByRole('button', { name: 'Cancel' }))
|
||||
const picker = within(screen.getByRole('group', { name: 'Models' }))
|
||||
expect(picker.getByRole('button', { name: 'gpt-4o-all' })).toBeVisible()
|
||||
expect(
|
||||
picker.queryByRole('button', { name: 'gpt-4o' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(panel.getByRole('button', { name: 'Batch Add' }))
|
||||
dialog = within(screen.getByRole('dialog', { name: 'Batch add mappings' }))
|
||||
expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'gpt-4ogpt-4o-all'
|
||||
)
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(
|
||||
panel.getAllByRole('combobox', { name: 'Request Model Name' })
|
||||
).toHaveLength(2)
|
||||
expect(screen.getByLabelText('Published as gpt-4o')).toBeVisible()
|
||||
expect(screen.getByLabelText('Published as o3')).toBeVisible()
|
||||
const picker = within(screen.getByRole('group', { name: 'Models' }))
|
||||
).toHaveLength(1)
|
||||
expect(picker.getByRole('button', { name: 'gpt-4o' })).toBeVisible()
|
||||
expect(picker.getByRole('button', { name: 'o3' })).toBeVisible()
|
||||
expect(screen.getByText('2 model(s) redirected')).toBeVisible()
|
||||
expect(picker.getByRole('button', { name: 'o3-all' })).toBeVisible()
|
||||
expect(picker.queryByRole('button', { name: 'o3' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 model(s) redirected')).toBeVisible()
|
||||
|
||||
await user.click(panel.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(
|
||||
@@ -2595,3 +2798,138 @@ test('the header override passthrough template button confirms before filling th
|
||||
)
|
||||
).toBeChecked()
|
||||
})
|
||||
|
||||
test('a New API channel binds upstream task plugins and publishes their models', async () => {
|
||||
const channel = channelSchema.parse({
|
||||
...editingChannel,
|
||||
type: 60,
|
||||
base_url: 'https://gateway.example',
|
||||
models: 'gpt-5',
|
||||
setting: JSON.stringify({ task_extend_plugin_keys: ['video-a'] }),
|
||||
})
|
||||
const originalGet = vi.mocked(api.get).getMockImplementation()
|
||||
vi.mocked(api.get).mockImplementation(async (url, config) => {
|
||||
if (url === '/api/channel/42') {
|
||||
return { data: { success: true, data: channel } }
|
||||
}
|
||||
return originalGet?.(url, config)
|
||||
})
|
||||
const put = vi
|
||||
.spyOn(api, 'put')
|
||||
.mockResolvedValue({ data: { success: true } })
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={channel} />)
|
||||
const extensions = within(
|
||||
await screen.findByRole('group', { name: 'Plugin extensions' })
|
||||
)
|
||||
expect(
|
||||
extensions.getByRole('button', { name: 'Video A Selected 0 / 1' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
extensions.queryByRole('button', { name: /^Video B/ })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const selector = screen.getByRole('combobox', {
|
||||
name: 'Select the task plugins installed on the upstream gateway',
|
||||
})
|
||||
await user.click(selector)
|
||||
expect(
|
||||
screen.queryByRole('option', { name: /^No Address/ })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('option', { name: /^Video B/ }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(
|
||||
extensions.getByRole('button', { name: 'Video B Selected 1 / 1' })
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'video-b-1' })).toBeVisible()
|
||||
|
||||
await user.click(selector)
|
||||
await user.click(screen.getByRole('option', { name: /^Video A/ }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(
|
||||
extensions.queryByRole('button', { name: /^Video A/ })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'gpt-5' })).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalled())
|
||||
const payload = put.mock.calls[0]?.[1] as {
|
||||
setting?: string
|
||||
models?: string
|
||||
}
|
||||
const setting = JSON.parse(payload.setting ?? '{}')
|
||||
expect(setting).toMatchObject({ task_extend_plugin_keys: ['video-b'] })
|
||||
expect(setting).not.toHaveProperty('task_plugin_key')
|
||||
expect(payload.models?.split(',').sort()).toEqual(['gpt-5', 'video-b-1'])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
saved: undefined,
|
||||
shown: 'InvokeModel (legacy)',
|
||||
next: 'Mantle (Messages API)',
|
||||
value: 'mantle',
|
||||
},
|
||||
{
|
||||
saved: 'mantle',
|
||||
shown: 'Mantle (Messages API)',
|
||||
next: 'InvokeModel (legacy)',
|
||||
value: 'invoke_model',
|
||||
},
|
||||
])(
|
||||
'AWS API defaults or restores $shown and saves the selected interface',
|
||||
async ({ saved, shown, next, value }) => {
|
||||
editingChannel = {
|
||||
...editingChannel,
|
||||
type: 33,
|
||||
base_url: '',
|
||||
models: 'claude-opus-4-7',
|
||||
settings: JSON.stringify({
|
||||
aws_key_type: 'api_key',
|
||||
aws_api_type: saved,
|
||||
}),
|
||||
}
|
||||
const put = vi
|
||||
.spyOn(api, 'put')
|
||||
.mockResolvedValue({ data: { success: true } })
|
||||
const user = userEvent.setup()
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
const selector = await screen.findByRole('combobox', { name: 'AWS API' })
|
||||
await waitFor(() => expect(selector).toHaveTextContent(shown))
|
||||
await user.click(selector)
|
||||
await user.click(screen.getByRole('option', { name: next }))
|
||||
expect(selector).toHaveTextContent(next)
|
||||
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalled())
|
||||
const payload = put.mock.calls[0]?.[1] as { settings: string }
|
||||
expect(JSON.parse(payload.settings)).toMatchObject({
|
||||
aws_key_type: 'api_key',
|
||||
aws_api_type: value,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('AWS interface selection is disabled without sensitive channel write permission', async () => {
|
||||
editingChannel = {
|
||||
...editingChannel,
|
||||
type: 33,
|
||||
settings: '{"aws_api_type":"mantle"}',
|
||||
}
|
||||
useAuthStore.setState({
|
||||
auth: {
|
||||
...originalAuth,
|
||||
user: {
|
||||
id: 10,
|
||||
username: 'operator',
|
||||
role: ROLE.ADMIN,
|
||||
permissions: {
|
||||
admin_permissions: { channel: { read: true, write: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<ConfigurationHarness currentRow={editingChannel} />)
|
||||
expect(
|
||||
await screen.findByRole('combobox', { name: 'AWS API' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { channelSchema, type Channel } from '../../types'
|
||||
import { useChannelsColumns } from '../channels-columns'
|
||||
import { ChannelsProvider } from '../channels-provider'
|
||||
|
||||
function ExampleAutoDisabledStatusCell(props: { channel: Channel }) {
|
||||
const table = useReactTable({
|
||||
data: [props.channel],
|
||||
columns: useChannelsColumns({ enableSelection: false }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
const cell = table
|
||||
.getRowModel()
|
||||
.rows[0]?.getAllCells()
|
||||
.find((item) => item.column.id === 'status')
|
||||
|
||||
return cell ? flexRender(cell.column.columnDef.cell, cell.getContext()) : null
|
||||
}
|
||||
|
||||
test('keeps the long string inside the status tooltip is wrapped when show', async () => {
|
||||
const reason = '114514'.repeat(40)
|
||||
const channelItem = channelSchema.parse({
|
||||
id: 1,
|
||||
type: 1,
|
||||
key: 'test-key',
|
||||
name: 'Test-Channel-Item',
|
||||
status: 3,
|
||||
created_time: 1,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
other_info: JSON.stringify({ status_reason: reason }),
|
||||
})
|
||||
const queryClient = new QueryClient()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChannelsProvider>
|
||||
<ExampleAutoDisabledStatusCell channel={channelItem} />
|
||||
</ChannelsProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await user.hover(screen.getByText('Auto Disabled'))
|
||||
|
||||
expect(await screen.findByText(reason, { exact: false })).toHaveClass(
|
||||
'wrap-anywhere'
|
||||
)
|
||||
})
|
||||
+185
-58
@@ -18,11 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import fr from '@/i18n/locales/fr.json'
|
||||
|
||||
import { ModelMappingBatchDialog } from '../model-mapping-batch-dialog'
|
||||
|
||||
test('upstream names are the default source: stripping a suffix derives request names, skips unchanged models, and syncs the model list', async () => {
|
||||
test('creating user aliases starts unselected, previews request-to-upstream pairs, and skips unchanged names', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
@@ -30,113 +34,236 @@ test('upstream names are the default source: stripping a suffix derives request
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all', 'o3']}
|
||||
channelModels={['gpt-4o']}
|
||||
channelModels={['gpt-4o-all']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(
|
||||
dialog.getByRole('tab', { name: /Upstream model list/ })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
dialog.getByRole('radio', {
|
||||
name: 'Upstream names returned by the provider',
|
||||
})
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
).toBeChecked()
|
||||
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
|
||||
expect(
|
||||
dialog.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'o3' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'gpt-4ogpt-4o-all'
|
||||
expect(screen.getByRole('combobox', { name: 'Rule' })).toHaveTextContent(
|
||||
'Strip suffix'
|
||||
)
|
||||
expect(screen.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked()
|
||||
expect(
|
||||
dialog.getByText('1 model(s) unchanged by the rule were skipped')
|
||||
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Example: users call gpt-4o; upstream receives gpt-4o-all.'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'o3' }))
|
||||
expect(screen.getByText('Enter a rule to preview mappings.')).toBeVisible()
|
||||
expect(screen.queryByText(/unchanged by the rule/)).not.toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
const preview = within(screen.getByRole('list', { name: 'Preview' }))
|
||||
expect(preview.getByRole('listitem')).toHaveTextContent('gpt-4ogpt-4o-all')
|
||||
expect(
|
||||
screen.getByText('1 model(s) unchanged by the rule were skipped')
|
||||
).toBeVisible()
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'gpt-4o', to: 'gpt-4o-all' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('the channel-model source flips to request names, offers add rules, and never syncs the model list', async () => {
|
||||
test('changing upstream names preserves the request name and leaves model-list syncing off', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['x-all']}
|
||||
channelModels={['gemini-2.5-flash']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
await user.click(dialog.getByRole('tab', { name: /Channel models/ }))
|
||||
expect(
|
||||
dialog.getByRole('radio', { name: 'Request names your users call' })
|
||||
screen.getByRole('radio', { name: 'Change upstream model names' })
|
||||
).toBeChecked()
|
||||
expect(dialog.getByRole('radio', { name: 'Add suffix' })).toBeChecked()
|
||||
expect(
|
||||
dialog.queryByRole('checkbox', { name: /Publish the request names/ })
|
||||
screen.queryByRole('checkbox', { name: /Publish the request names/ })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
|
||||
await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }],
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('without an upstream list the dialog offers only channel models, and conflicting request names are reported', async () => {
|
||||
test('switching the model list clears selection without changing the chosen task or naming rule', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all']}
|
||||
channelModels={['claude-all']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('tab', { name: /Channel models/ }))
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByRole('textbox', { name: 'Suffix' })).toHaveValue('-all')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add 0 mapping(s)' })
|
||||
).toBeDisabled()
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'claude', to: 'claude-all' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('creating aliases from channel models reports conflicts and allows opting out of model-list syncing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
channelModels={['claude-all', 'claude-latest']}
|
||||
onApply={vi.fn()}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
const dialog = within(
|
||||
screen.getByRole('dialog', { name: 'Batch add mappings' })
|
||||
)
|
||||
expect(dialog.queryByRole('tab')).not.toBeInTheDocument()
|
||||
expect(
|
||||
dialog.getByRole('radio', { name: 'Request names your users call' })
|
||||
).toBeChecked()
|
||||
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
dialog.getByRole('radio', {
|
||||
name: 'Upstream names returned by the provider',
|
||||
})
|
||||
screen.getByRole('radio', { name: 'Create aliases for users' })
|
||||
)
|
||||
expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked()
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(dialog.getByRole('checkbox', { name: 'claude-latest' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-all' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'claude-latest' }))
|
||||
await user.type(
|
||||
dialog.getByRole('textbox', { name: 'Suffix' }),
|
||||
screen.getByRole('textbox', { name: 'Suffix' }),
|
||||
'-all, -latest'
|
||||
)
|
||||
expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent(
|
||||
'claudeclaude-all'
|
||||
)
|
||||
expect(
|
||||
dialog.getByText(
|
||||
screen.getByText(
|
||||
'1 model(s) skipped because another model derives the same request name'
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })).toBeEnabled()
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: /Publish the request names/ })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'claude', to: 'claude-all' }],
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('the alias task can add a prefix and only shows fields for the selected rule', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o']}
|
||||
channelModels={[]}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'Prefix' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Add prefix' }))
|
||||
expect(
|
||||
screen.queryByRole('textbox', { name: 'Suffix' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'Prefix' }), 'team/')
|
||||
await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: [{ from: 'team/gpt-4o', to: 'gpt-4o' }],
|
||||
syncModels: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('replacement previews include every selected model even when several share an upstream target', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const models = [
|
||||
'gpt-4o-a',
|
||||
'gpt-4oa',
|
||||
...Array.from({ length: 8 }, (_, n) => `gpt-${n}`),
|
||||
]
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
channelModels={models}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select all models in OpenAI' })
|
||||
)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Rule' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Replace text' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Find' }), '-')
|
||||
const preview = within(screen.getByRole('list', { name: 'Preview' }))
|
||||
expect(preview.getAllByRole('listitem')).toHaveLength(10)
|
||||
expect(preview.getAllByText('gpt4oa')).toHaveLength(2)
|
||||
await user.click(screen.getByRole('button', { name: 'Add 10 mapping(s)' }))
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
pairs: models.map((from) => ({ from, to: from.replaceAll('-', '') })),
|
||||
syncModels: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('cancelling after configuring a rule does not apply mappings', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
channelModels={['gpt-4o']}
|
||||
onApply={onApply}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all')
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('long translated source labels wrap inside their row without covering the model search', async () => {
|
||||
const i18n = createInstance()
|
||||
await i18n.init({ lng: 'fr', resources: { fr }, keySeparator: false })
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ModelMappingBatchDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
upstreamModels={['gpt-4o-all']}
|
||||
channelModels={['gpt-4o']}
|
||||
onApply={vi.fn()}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
)
|
||||
const tabs = screen.getByRole('tablist', { name: 'Sélectionner les modèles' })
|
||||
expect(tabs).toHaveClass('grid-cols-2', 'group-data-horizontal/tabs:h-auto')
|
||||
for (const tab of within(tabs).getAllByRole('tab')) {
|
||||
expect(tab).toHaveClass('whitespace-normal', 'h-auto')
|
||||
}
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'Rechercher des modèles...' })
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -190,15 +190,15 @@ test('a draft request with only the upstream name focuses the request field, and
|
||||
draftRequest={{ from: '', to: 'up-1', token: 1 }}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('combobox', { name: 'Request Model Name' })
|
||||
).toHaveLength(2)
|
||||
)
|
||||
// The row is committed before its passive effect moves focus.
|
||||
await waitFor(() => {
|
||||
const requestFields = screen.getAllByRole('combobox', {
|
||||
name: 'Request Model Name',
|
||||
})
|
||||
expect(requestFields).toHaveLength(2)
|
||||
expect(requestFields[1]).toHaveFocus()
|
||||
})
|
||||
expect(screen.getByDisplayValue('up-1')).toBeVisible()
|
||||
expect(
|
||||
screen.getAllByRole('combobox', { name: 'Request Model Name' })[1]
|
||||
).toHaveFocus()
|
||||
|
||||
view.rerender(
|
||||
<ModelMappingEditor
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeAll, expect, test, vi } from 'vitest'
|
||||
|
||||
import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog'
|
||||
|
||||
const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations'
|
||||
)
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'getAnimations', {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (getAnimationsDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
'getAnimations',
|
||||
getAnimationsDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations')
|
||||
}
|
||||
})
|
||||
|
||||
async function renderPruneRules(
|
||||
conditions: { path: string; mode: string; value: string }[]
|
||||
) {
|
||||
const onSave = vi.fn()
|
||||
render(
|
||||
<ParamOverrideEditorDialog
|
||||
open
|
||||
value={JSON.stringify({
|
||||
operations: [
|
||||
{ mode: 'prune_objects', path: 'messages', value: { conditions } },
|
||||
],
|
||||
})}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
// Wait for the dialog's opening autofocus before testing user-driven focus.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Visual' })).toHaveFocus()
|
||||
)
|
||||
return onSave
|
||||
}
|
||||
|
||||
test('clearing and retyping a prune path keeps focus on that row and leaves the next rule unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = await renderPruneRules([
|
||||
{ path: 'a', mode: 'full', value: 'x' },
|
||||
{ path: 'b', mode: 'full', value: 'y' },
|
||||
])
|
||||
const firstPath = screen.getByDisplayValue('a')
|
||||
await user.clear(firstPath)
|
||||
expect(firstPath).toHaveValue('')
|
||||
expect(firstPath).toHaveFocus()
|
||||
expect(screen.getByDisplayValue('b')).toBeVisible()
|
||||
await user.type(firstPath, 'z')
|
||||
expect(firstPath).toHaveFocus()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
mode: 'prune_objects',
|
||||
path: 'messages',
|
||||
value: {
|
||||
conditions: [
|
||||
{ path: 'z', mode: 'full', value: 'x' },
|
||||
{ path: 'b', mode: 'full', value: 'y' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a newly added prune condition stays editable after deleting another row and survives duplication and JSON round-trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = await renderPruneRules([
|
||||
{ path: 'type', mode: 'full', value: 'legacy' },
|
||||
])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Condition' })[0])
|
||||
const paths = screen.getAllByPlaceholderText('type')
|
||||
expect(paths).toHaveLength(2)
|
||||
await user.type(paths[1], 'role')
|
||||
await user.click(screen.getAllByRole('combobox', { name: 'Match Mode' })[1])
|
||||
await user.click(screen.getByRole('option', { name: 'Regex' }))
|
||||
await user.type(
|
||||
screen.getAllByRole('textbox', { name: 'Match Value (optional)' })[1],
|
||||
'(?i)^tool$'
|
||||
)
|
||||
// The first Delete button belongs to the whole operation.
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[1])
|
||||
expect(paths[1]).toBeInTheDocument()
|
||||
expect(screen.getAllByPlaceholderText('type')).toHaveLength(1)
|
||||
await user.click(screen.getByRole('button', { name: 'Duplicate' }))
|
||||
await user.click(screen.getByRole('button', { name: 'JSON Text' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Visual' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
const expected = {
|
||||
mode: 'prune_objects',
|
||||
path: 'messages',
|
||||
value: {
|
||||
conditions: [{ path: 'role', mode: 'regex', value: '(?i)^tool$' }],
|
||||
},
|
||||
}
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [expected, expected],
|
||||
})
|
||||
})
|
||||
|
||||
test('switching prune simple and advanced modes retains unfinished rows but saving omits empty paths', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = await renderPruneRules([
|
||||
{ path: 'a', mode: 'regex', value: '^x$' },
|
||||
{ path: 'b', mode: 'full', value: 'y' },
|
||||
])
|
||||
await user.clear(screen.getByDisplayValue('a'))
|
||||
await user.click(screen.getByRole('button', { name: 'Simple' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Advanced' }))
|
||||
const paths = screen.getAllByPlaceholderText('type')
|
||||
expect(paths).toHaveLength(2)
|
||||
expect(paths[0]).toHaveValue('')
|
||||
expect(paths[1]).toHaveValue('b')
|
||||
expect(
|
||||
screen.getAllByRole('combobox', { name: 'Match Mode' })[0]
|
||||
).toHaveValue('Regex')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
mode: 'prune_objects',
|
||||
path: 'messages',
|
||||
value: { conditions: [{ path: 'b', mode: 'full', value: 'y' }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(paths[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('saving an unfinished prune rule is blocked without discarding the draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = await renderPruneRules([])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Condition' })[0])
|
||||
const path = screen.getByPlaceholderText('type')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(path).toBeVisible()
|
||||
await user.type(path, 'role')
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'Match Value (optional)' }),
|
||||
'tool'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
mode: 'prune_objects',
|
||||
path: 'messages',
|
||||
value: { conditions: [{ path: 'role', mode: 'full', value: 'tool' }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('an imported prune rule with an explicit empty type is preserved when saved unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
const payload = {
|
||||
operations: [
|
||||
{ mode: 'prune_objects', path: 'messages', value: { type: '' } },
|
||||
],
|
||||
}
|
||||
render(
|
||||
<ParamOverrideEditorDialog
|
||||
open
|
||||
value={JSON.stringify(payload)}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual(payload)
|
||||
})
|
||||
|
||||
test('selecting Regex saves a literal Go pattern with the condition flags', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
render(
|
||||
<ParamOverrideEditorDialog
|
||||
open
|
||||
value={JSON.stringify({
|
||||
operations: [
|
||||
{
|
||||
mode: 'set',
|
||||
path: 'matched',
|
||||
value: true,
|
||||
conditions: [{ path: 'model', mode: 'full', value: 'gpt' }],
|
||||
},
|
||||
],
|
||||
})}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Expand All' }))
|
||||
const mode = screen.getByRole('combobox', { name: 'Match Mode' })
|
||||
await user.click(mode)
|
||||
expect(mode).toHaveAttribute('aria-expanded', 'true')
|
||||
await user.click(screen.getByRole('option', { name: 'Regex' }))
|
||||
expect(mode).toHaveValue('Regex')
|
||||
const pattern = String.raw`(?i)^gpt-\d`
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Match Value' }), {
|
||||
target: { value: pattern },
|
||||
})
|
||||
await user.click(screen.getByRole('switch', { name: 'Invert match' }))
|
||||
await user.click(
|
||||
screen.getByRole('switch', { name: 'Pass when key is missing' })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
mode: 'set',
|
||||
path: 'matched',
|
||||
value: true,
|
||||
logic: 'OR',
|
||||
conditions: [
|
||||
{
|
||||
path: 'model',
|
||||
mode: 'regex',
|
||||
value: pattern,
|
||||
invert: true,
|
||||
pass_missing_key: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('importing and duplicating regex rules preserves patterns through JSON and visual modes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
const conditions = [
|
||||
...['1e3', 'null', '[0, 1]', '"quoted"', ' padded ', ''].map((value) => ({
|
||||
path: 'model',
|
||||
mode: 'regex',
|
||||
value,
|
||||
})),
|
||||
{ path: 'seed', mode: 'full', value: 1000 },
|
||||
]
|
||||
const operation = {
|
||||
mode: 'set',
|
||||
path: 'matched',
|
||||
value: true,
|
||||
logic: 'AND',
|
||||
conditions,
|
||||
}
|
||||
render(
|
||||
<ParamOverrideEditorDialog
|
||||
open
|
||||
value={JSON.stringify({ operations: [operation] })}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Duplicate' }))
|
||||
await user.click(screen.getByRole('button', { name: 'JSON Text' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Visual' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [operation, operation],
|
||||
})
|
||||
})
|
||||
|
||||
test('prune-object conditions offer Regex and preserve literal and empty patterns when edited', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
const operation = {
|
||||
mode: 'prune_objects',
|
||||
path: 'messages',
|
||||
value: {
|
||||
conditions: [{ path: 'role', mode: 'full', value: 'system' }],
|
||||
},
|
||||
}
|
||||
render(
|
||||
<ParamOverrideEditorDialog
|
||||
open
|
||||
value={JSON.stringify({ operations: [operation] })}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Match Mode' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Regex' }))
|
||||
const matchValue = screen.getByRole('textbox', {
|
||||
name: 'Match Value (optional)',
|
||||
})
|
||||
fireEvent.change(matchValue, { target: { value: ' 1e3 ' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(JSON.parse(onSave.mock.calls[0][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
...operation,
|
||||
value: {
|
||||
conditions: [{ path: 'role', mode: 'regex', value: ' 1e3 ' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await user.clear(matchValue)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSave).toHaveBeenCalledTimes(2)
|
||||
expect(JSON.parse(onSave.mock.calls[1][0])).toEqual({
|
||||
operations: [
|
||||
{
|
||||
...operation,
|
||||
value: {
|
||||
conditions: [{ path: 'role', mode: 'regex', value: '' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { StrictMode, useState } from 'react'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../../constants'
|
||||
import { ChannelPriorityModeToggle } from '../channel-priority-controls'
|
||||
|
||||
function Fixture(props: { reason?: 'sort'; viewMode?: 'card' | 'table' }) {
|
||||
const [active, setActive] = useState(false)
|
||||
return (
|
||||
<ChannelPriorityModeToggle
|
||||
active={active}
|
||||
viewMode={props.viewMode ?? 'table'}
|
||||
reason={props.reason ?? null}
|
||||
onChange={() => setActive((value) => !value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
test('drag mode and its help are presented as one accessible control group', () => {
|
||||
render(<Fixture />)
|
||||
const group = within(screen.getByRole('group', { name: 'Drag mode' }))
|
||||
expect(group.getByRole('button', { name: 'Drag mode' })).toBeVisible()
|
||||
expect(group.getByRole('button', { name: 'Drag mode help' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('seeing the table guide does not hide the first card guide', async () => {
|
||||
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS.table, 'seen')
|
||||
const user = userEvent.setup()
|
||||
const page = render(<Fixture viewMode='card' />)
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
const guide = within(await screen.findByRole('dialog'))
|
||||
expect(guide.getByRole('list', { name: 'Card view' })).toBeVisible()
|
||||
await user.click(guide.getByRole('button', { name: 'Got it' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
page.unmount()
|
||||
render(<Fixture viewMode='card' />)
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('first activation shows the guide and remembers dismissal across visits', async () => {
|
||||
const user = userEvent.setup()
|
||||
const page = render(
|
||||
<StrictMode>
|
||||
<Fixture />
|
||||
</StrictMode>
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
const guide = await screen.findByRole('dialog', {
|
||||
name: 'Drag to set channel priority',
|
||||
})
|
||||
expect(within(guide).getByText('Join the same priority tier')).toBeVisible()
|
||||
expect(within(guide).getByText('Insert a new priority tier')).toBeVisible()
|
||||
await user.click(within(guide).getByRole('button', { name: 'Got it' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.table)).toBe('seen')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Finish reordering' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
page.unmount()
|
||||
render(<Fixture />)
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('returning users can reopen the demo and Escape returns focus to help', async () => {
|
||||
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS.table, 'seen')
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
const help = screen.getByRole('button', { name: 'Drag mode help' })
|
||||
help.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Drag to set channel priority' })
|
||||
).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
await waitFor(() => expect(help).toHaveFocus())
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
test('an unavailable drag mode keeps help accessible without enabling reordering', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture reason='sort' />)
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Got it' }))
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
test('unavailable browser storage does not block the guide or repeat it within the visit', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('Unavailable')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('Unavailable')
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Got it' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Finish reordering' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s guide automatically plays both distinct demos and loops after showing each result',
|
||||
(viewMode) => {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: false,
|
||||
}))
|
||||
vi.useFakeTimers()
|
||||
render(<Fixture viewMode={viewMode} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
const guide = within(screen.getByRole('dialog'))
|
||||
const joinTab = guide.getByRole('tab', {
|
||||
name: 'Join the same priority tier',
|
||||
})
|
||||
const insertTab = guide.getByRole('tab', {
|
||||
name: 'Insert a new priority tier',
|
||||
})
|
||||
expect(joinTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
guide.getByRole('list', {
|
||||
name: viewMode === 'card' ? 'Card view' : 'Table view',
|
||||
})
|
||||
).toBeVisible()
|
||||
act(() => vi.advanceTimersByTime(1600))
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Release to join priority 80'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(1400))
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
|
||||
act(() => vi.advanceTimersByTime(2199))
|
||||
expect(joinTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(insertTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Drag the handle to move a channel.'
|
||||
)
|
||||
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 0'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(1600))
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Release to insert a new priority tier'
|
||||
)
|
||||
if (viewMode === 'table') {
|
||||
expect(
|
||||
guide.getAllByRole('listitem').map((row) => row.textContent)
|
||||
).toEqual([
|
||||
'Channel APriority 100',
|
||||
'Channel BPriority 80',
|
||||
'Channel CPriority 90',
|
||||
])
|
||||
}
|
||||
act(() => vi.advanceTimersByTime(1400))
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
|
||||
expect(
|
||||
guide.getAllByRole('listitem').map((row) => row.textContent)
|
||||
).toEqual([
|
||||
'Channel APriority 100',
|
||||
'Channel CPriority 90',
|
||||
'Channel BPriority 80',
|
||||
])
|
||||
act(() => vi.advanceTimersByTime(2200))
|
||||
expect(joinTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 0'
|
||||
)
|
||||
fireEvent.click(guide.getByRole('button', { name: 'Got it' }))
|
||||
act(() => vi.advanceTimersByTime(10000))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const reopened = within(screen.getByRole('dialog'))
|
||||
expect(
|
||||
reopened.getByRole('tab', { name: 'Join the same priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(reopened.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 0'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('autoplay can pause, resume from the same point, and replay the complete sequence', () => {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: false,
|
||||
}))
|
||||
vi.useFakeTimers()
|
||||
render(<Fixture />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const guide = within(screen.getByRole('dialog'))
|
||||
act(() => vi.advanceTimersByTime(1600))
|
||||
fireEvent.click(guide.getByRole('button', { name: 'Pause drag demo' }))
|
||||
act(() => vi.advanceTimersByTime(15000))
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Release to join priority 80'
|
||||
)
|
||||
fireEvent.click(guide.getByRole('button', { name: 'Resume drag demo' }))
|
||||
act(() => vi.advanceTimersByTime(1399))
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Release to join priority 80'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
|
||||
act(() => vi.advanceTimersByTime(2200))
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Insert a new priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
act(() => vi.advanceTimersByTime(1600))
|
||||
fireEvent.click(guide.getByRole('button', { name: 'Replay drag demo' }))
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Join the same priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 0'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(5200))
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Insert a new priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('manually selecting a demo starts its own complete playback without an old automatic switch', () => {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: false,
|
||||
}))
|
||||
vi.useFakeTimers()
|
||||
render(<Fixture />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const guide = within(screen.getByRole('dialog'))
|
||||
const insertTab = guide.getByRole('tab', {
|
||||
name: 'Insert a new priority tier',
|
||||
})
|
||||
act(() => vi.advanceTimersByTime(4800))
|
||||
fireEvent.click(insertTab)
|
||||
act(() => vi.advanceTimersByTime(400))
|
||||
expect(insertTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Drag the handle to move a channel.'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(1200))
|
||||
expect(guide.getByRole('status')).toHaveTextContent(
|
||||
'Release to insert a new priority tier'
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(3600))
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Join the same priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('reduced motion shows the result of the selected demo immediately', async () => {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
}))
|
||||
render(<Fixture />)
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const guide = within(await screen.findByRole('dialog'))
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 80')
|
||||
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 80'
|
||||
)
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(guide.getByRole('tab', { name: 'Insert a new priority tier' }))
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
|
||||
expect(guide.getByText('Channel C').closest('li')).toHaveTextContent(
|
||||
'Priority 90'
|
||||
)
|
||||
})
|
||||
|
||||
test('keyboard selection switches to the other explicitly named demo', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const guide = within(await screen.findByRole('dialog'))
|
||||
guide.getByRole('tab', { name: 'Join the same priority tier' }).focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Insert a new priority tier' })
|
||||
).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(
|
||||
guide.getByRole('tab', { name: 'Insert a new priority tier' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(guide.getByRole('status')).toHaveTextContent('New priority: 90')
|
||||
})
|
||||
@@ -0,0 +1,889 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../../constants'
|
||||
import type {
|
||||
PriorityMove,
|
||||
PriorityOrder,
|
||||
} from '../../lib/channel-priority-order'
|
||||
import type { Channel } from '../../types'
|
||||
import { ChannelsPrimaryButtons } from '../channels-primary-buttons'
|
||||
import { ChannelsProvider } from '../channels-provider'
|
||||
import { ChannelsTable } from '../channels-table'
|
||||
|
||||
const initial: PriorityOrder = {
|
||||
revision: 'initial',
|
||||
items: [
|
||||
{ id: 1, priority: 100 },
|
||||
{ id: 2, priority: 80 },
|
||||
{ id: 3, priority: 0 },
|
||||
],
|
||||
changes: [],
|
||||
}
|
||||
const applied: PriorityOrder = {
|
||||
revision: 'applied',
|
||||
items: [
|
||||
{ id: 1, priority: 100 },
|
||||
{ id: 2, priority: 80 },
|
||||
{ id: 3, priority: 90 },
|
||||
],
|
||||
changes: [{ id: 3, previous: 0, priority: 90 }],
|
||||
}
|
||||
let current: PriorityOrder
|
||||
let requests: PriorityMove[]
|
||||
let failSave: boolean
|
||||
const pointerMethods = [
|
||||
'setPointerCapture',
|
||||
'releasePointerCapture',
|
||||
'hasPointerCapture',
|
||||
] as const
|
||||
const originalPointerMethods = pointerMethods.map(
|
||||
(name) =>
|
||||
[
|
||||
name,
|
||||
Object.getOwnPropertyDescriptor(HTMLElement.prototype, name),
|
||||
] as const
|
||||
)
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const [name, descriptor] of originalPointerMethods) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, descriptor)
|
||||
} else Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
}
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
function channel(item: { id: number; priority: number | null }): Channel {
|
||||
return {
|
||||
id: item.id,
|
||||
priority: item.priority,
|
||||
name: `Channel ${item.id}`,
|
||||
type: 1,
|
||||
status: 1,
|
||||
group: 'default',
|
||||
models: 'test-model',
|
||||
weight: 10,
|
||||
balance: 0,
|
||||
response_time: 0,
|
||||
test_time: 0,
|
||||
channel_info: {},
|
||||
} as Channel
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
current = initial
|
||||
requests = []
|
||||
failSave = false
|
||||
localStorage.clear()
|
||||
for (const key of Object.values(CHANNEL_PRIORITY_GUIDE_KEYS)) {
|
||||
localStorage.setItem(key, 'seen')
|
||||
}
|
||||
useAuthStore.getState().auth.setUser({
|
||||
id: 1,
|
||||
username: 'test',
|
||||
display_name: 'Test',
|
||||
role: 100,
|
||||
group: 'default',
|
||||
})
|
||||
vi.spyOn(api, 'get').mockImplementation(async (url, config) => {
|
||||
if (url === '/api/channel/priority/order') {
|
||||
return { data: { success: true, data: current } }
|
||||
}
|
||||
if (url === '/api/channel' || url === '/api/channel/search') {
|
||||
const field = config?.params?.sort_by ?? 'priority'
|
||||
const desc = config?.params?.sort_order !== 'asc'
|
||||
const items = current.items
|
||||
.map(channel)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
((Number(a[field as keyof Channel]) || 0) -
|
||||
(Number(b[field as keyof Channel]) || 0)) *
|
||||
(desc ? -1 : 1) || b.id - a.id
|
||||
)
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: { items, total: 3, type_counts: { 1: 3 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (url === '/api/group/') {
|
||||
return { data: { success: true, data: ['default'] } }
|
||||
}
|
||||
return { data: { success: true, data: [] } }
|
||||
})
|
||||
vi.spyOn(api, 'post').mockImplementation(async (url, body) => {
|
||||
if (url !== '/api/channel/priority/reorder') {
|
||||
return { data: { success: true } }
|
||||
}
|
||||
const move = body as PriorityMove
|
||||
requests.push(move)
|
||||
if (failSave && !move.preview) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Channel priorities changed. Refresh and try again.',
|
||||
},
|
||||
}
|
||||
}
|
||||
const joined: PriorityOrder = {
|
||||
revision: 'joined',
|
||||
items: [
|
||||
{ id: 1, priority: 100 },
|
||||
{ id: 2, priority: 80 },
|
||||
{ id: 3, priority: 80 },
|
||||
],
|
||||
changes: [{ id: 3, previous: 0, priority: 80 }],
|
||||
}
|
||||
let result = applied
|
||||
if (move.restore) result = initial
|
||||
else if (move.position === 'same') result = joined
|
||||
if (!move.preview) current = result
|
||||
return { data: { success: true, data: result } }
|
||||
})
|
||||
})
|
||||
|
||||
async function mount(view: 'table' | 'card' = 'card', enabled = true) {
|
||||
localStorage.setItem('channels:view-mode', view)
|
||||
const root = createRootRoute()
|
||||
const authenticated = createRoute({
|
||||
getParentRoute: () => root,
|
||||
id: '_authenticated',
|
||||
})
|
||||
const channels = createRoute({
|
||||
getParentRoute: () => authenticated,
|
||||
path: '/channels/',
|
||||
validateSearch: (search) => ({ page: 1, pageSize: 10, ...search }),
|
||||
component: () => (
|
||||
<ChannelsProvider>
|
||||
<header>
|
||||
<ChannelsPrimaryButtons />
|
||||
</header>
|
||||
<ChannelsTable />
|
||||
</ChannelsProvider>
|
||||
),
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([authenticated.addChildren([channels])]),
|
||||
history: createMemoryHistory({ initialEntries: ['/channels/'] }),
|
||||
})
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
await screen.findByRole('button', { name: 'Drag mode' })
|
||||
if (enabled) {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
|
||||
)
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole('button', { name: 'Drag mode' }))
|
||||
await screen.findByRole('button', { name: 'Reorder channel 3' })
|
||||
}
|
||||
return { client, router }
|
||||
}
|
||||
|
||||
function orderInView() {
|
||||
return [...document.querySelectorAll<HTMLElement>('[data-sortable-id]')].map(
|
||||
(element) => element.dataset.sortableId
|
||||
)
|
||||
}
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s view opens the matching tutorial without enabling drag mode',
|
||||
async (view) => {
|
||||
await mount(view, false)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Drag mode help' }))
|
||||
const guide = within(await screen.findByRole('dialog'))
|
||||
expect(
|
||||
guide.getByRole('list', {
|
||||
name: view === 'card' ? 'Card view' : 'Table view',
|
||||
})
|
||||
).toBeVisible()
|
||||
await user.click(guide.getByRole('button', { name: 'Got it' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
expect(requests).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s keyboard reorder saves priority and undo restores it',
|
||||
async (view) => {
|
||||
await mount(view)
|
||||
const user = userEvent.setup()
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ key: 'ArrowUp' }
|
||||
)
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
await waitFor(() =>
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
|
||||
)
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
|
||||
])
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
await waitFor(() =>
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
|
||||
)
|
||||
expect(requests.at(-1)).toEqual({
|
||||
revision: 'applied',
|
||||
restore: [{ id: 3, previous: 90, priority: 0 }],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('switching sort exits reorder mode without automatically reopening it', async () => {
|
||||
await mount()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
|
||||
await user.click(await screen.findByRole('option', { name: /^ID$/ }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reorder channel 3' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('combobox', { name: 'Sort channels by' }))
|
||||
await user.click(await screen.findByRole('option', { name: /^Priority$/ }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled()
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reorder channel 3' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
test('failed save restores the visible order and does not offer undo', async () => {
|
||||
failSave = true
|
||||
await mount()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
|
||||
key: 'ArrowUp',
|
||||
})
|
||||
await waitFor(() => expect(requests).toHaveLength(1))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' })
|
||||
).toBeEnabled()
|
||||
)
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('card and table views share the saved order', async () => {
|
||||
await mount()
|
||||
const user = userEvent.setup()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), {
|
||||
key: 'ArrowUp',
|
||||
})
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
const views = within(screen.getByRole('group', { name: 'View mode' }))
|
||||
views.getByRole('button', { name: 'Card view' }).focus()
|
||||
await user.tab()
|
||||
expect(views.getByRole('button', { name: 'Table view' })).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await screen.findByRole('table')
|
||||
expect(views.getByRole('button', { name: 'Table view' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
expect(views.getByRole('button', { name: 'Card view' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2'])
|
||||
})
|
||||
|
||||
test('mobile view controls stay available with the filter panel collapsed', async () => {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: query === '(max-width: 640px)',
|
||||
}))
|
||||
await mount('card', false)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter by name, ID, or key...')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Table view' }))
|
||||
expect(await screen.findByRole('table')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'View' }))
|
||||
expect(
|
||||
await screen.findByRole('menuitemcheckbox', { name: 'Weight' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
function pointerGeometry(cardColumns = 3) {
|
||||
class TestPointerEvent extends MouseEvent {
|
||||
pointerId: number
|
||||
constructor(type: string, init: PointerEventInit) {
|
||||
super(type, init)
|
||||
this.pointerId = init.pointerId ?? 1
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('PointerEvent', TestPointerEvent)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
if (this.dataset.sortableId) {
|
||||
const index = [
|
||||
...document.querySelectorAll('[data-sortable-id]'),
|
||||
].indexOf(this)
|
||||
if (this.tagName === 'TR') {
|
||||
return new DOMRect(0, 100 + index * 60, 1000, 60)
|
||||
}
|
||||
return new DOMRect(
|
||||
(index % cardColumns) * 320,
|
||||
100 + Math.floor(index / cardColumns) * 220,
|
||||
300,
|
||||
200
|
||||
)
|
||||
}
|
||||
return new DOMRect(0, 0, 1200, 800)
|
||||
}
|
||||
)
|
||||
for (const name of [
|
||||
'setPointerCapture',
|
||||
'releasePointerCapture',
|
||||
'hasPointerCapture',
|
||||
]) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, {
|
||||
configurable: true,
|
||||
value: () => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
startY: 174,
|
||||
jitterY: 169,
|
||||
position: 'same',
|
||||
hint: 'Release to join priority 80',
|
||||
},
|
||||
{
|
||||
startY: 165,
|
||||
jitterY: 171,
|
||||
position: 'before',
|
||||
hint: 'Release to insert a new priority tier',
|
||||
},
|
||||
] as const)(
|
||||
'table preserves $position mode through small pointer movements',
|
||||
async ({ startY, jitterY, position, hint }) => {
|
||||
await mount('table')
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: startY })
|
||||
await screen.findByText(hint)
|
||||
fireEvent.pointerMove(scope, {
|
||||
pointerId: 1,
|
||||
clientX: 20,
|
||||
clientY: jitterY,
|
||||
})
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: jitterY })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position, revision: 'initial' },
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
test('the compact table preview respects masked channel names', async () => {
|
||||
await mount('table')
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /^Hide$/ }))
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
|
||||
await screen.findByText('Release to join priority 80')
|
||||
const ghost = document.querySelector('[data-origin-x]')
|
||||
expect(ghost).not.toHaveTextContent('Channel 3')
|
||||
expect(ghost).toHaveTextContent('••••')
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([])
|
||||
})
|
||||
|
||||
test('table targets stay in place and the compact preview leaves the target rows visible', async () => {
|
||||
await mount('table')
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 105 })
|
||||
await screen.findByText('Release to insert a new priority tier')
|
||||
for (const row of document.querySelectorAll<HTMLElement>(
|
||||
'[data-sortable-id]'
|
||||
)) {
|
||||
expect(row.style.transform).toBe('')
|
||||
}
|
||||
const ghost = document.querySelector<HTMLElement>('[data-origin-x]')
|
||||
expect(ghost?.querySelector('tr')).toBeNull()
|
||||
expect(
|
||||
Number.parseFloat(ghost?.style.width ?? 'Infinity')
|
||||
).toBeLessThanOrEqual(320)
|
||||
expect(ghost).toHaveTextContent('Channel 3')
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
|
||||
await screen.findByText('Release to join priority 80')
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: 190 })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
|
||||
])
|
||||
for (const row of document.querySelectorAll<HTMLElement>(
|
||||
'[data-sortable-id]'
|
||||
)) {
|
||||
expect(row.style.transform).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
test('dropping below the table rows cancels instead of selecting a distant row', async () => {
|
||||
await mount('table')
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 20, clientY: 250 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 190 })
|
||||
await screen.findByText('Release to join priority 80')
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 400 })
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Release to join priority 80')
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 20, clientY: 400 })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([])
|
||||
})
|
||||
|
||||
test('a card alone on the last grid row still uses horizontal edge targets', async () => {
|
||||
await mount()
|
||||
pointerGeometry(2)
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 20, clientY: 350 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
await screen.findByText('Release to insert a new priority tier')
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
|
||||
])
|
||||
})
|
||||
|
||||
test('a quick card pointer drag saves even when released before an animation frame', async () => {
|
||||
await mount()
|
||||
pointerGeometry()
|
||||
const handle = screen.getByRole('button', { name: 'Reorder channel 3' })
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(handle, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 660,
|
||||
clientY: 130,
|
||||
})
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
fireEvent.lostPointerCapture(scope, { pointerId: 1 })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'before', revision: 'initial' },
|
||||
])
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('Escape cancels a card drag without saving and removes its ghost', async () => {
|
||||
await mount()
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
|
||||
)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([])
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('moving from a card edge to its center joins the tier despite the moving placeholder', async () => {
|
||||
await mount()
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
|
||||
)
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
|
||||
expect(requests.every((request) => request.preview)).toBe(true)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 470, clientY: 150 })
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 470, clientY: 150 })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
|
||||
])
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('tag grouping disables reorder mode', async () => {
|
||||
localStorage.setItem('enable-tag-mode', 'true')
|
||||
await mount('card', false)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Drag mode',
|
||||
})
|
||||
).toBeDisabled()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s drag handles are hidden by default',
|
||||
async (view) => {
|
||||
await mount(view, false)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reorder channel 3' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s reordering can be explicitly enabled and finished',
|
||||
async (view) => {
|
||||
await mount(view, false)
|
||||
const user = userEvent.setup()
|
||||
screen.getByRole('button', { name: 'Drag mode' }).focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' })
|
||||
).toBeEnabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Finish reordering' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Finish reordering' }))
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reorder channel 3' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(requests).toEqual([])
|
||||
expect(api.get).not.toHaveBeenCalledWith('/api/channel/priority/order')
|
||||
}
|
||||
)
|
||||
|
||||
test('the page header provides the single sorting control without an ID switch', async () => {
|
||||
await mount('card', false)
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: 'Sort by ID' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('combobox', { name: 'Sort channels by' })
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
within(screen.getByRole('banner')).getByRole('combobox', {
|
||||
name: 'Sort channels by',
|
||||
})
|
||||
).toBeEnabled()
|
||||
expect(
|
||||
within(screen.getByRole('region', { name: 'Channels' })).queryByRole(
|
||||
'combobox',
|
||||
{ name: 'Sort channels by' }
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the header places reorder before batch mode while shared display controls stay in the table toolbar', async () => {
|
||||
await mount('card', false)
|
||||
const user = userEvent.setup()
|
||||
const header = within(screen.getByRole('banner'))
|
||||
const toolbar = within(screen.getByRole('region', { name: 'Channels' }))
|
||||
const reorder = header.getByRole('button', { name: 'Drag mode' })
|
||||
reorder.focus()
|
||||
await user.tab()
|
||||
expect(header.getByRole('button', { name: 'Drag mode help' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(header.getByRole('switch', { name: 'Batch Operations' })).toHaveFocus()
|
||||
expect(
|
||||
toolbar.queryByRole('button', { name: 'Drag mode' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(toolbar.getByRole('button', { name: 'Hide' })).toBeVisible()
|
||||
expect(toolbar.getByRole('group', { name: 'View mode' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('the header honors the saved ID preference and changes the shared sort direction', async () => {
|
||||
localStorage.setItem('channels-id-sort', 'true')
|
||||
await mount('card', false)
|
||||
const header = within(screen.getByRole('banner'))
|
||||
expect(
|
||||
header.getByRole('combobox', { name: 'Sort channels by' })
|
||||
).toHaveTextContent('ID')
|
||||
await waitFor(() =>
|
||||
expect(orderInView()).toEqual(['channel:3', 'channel:2', 'channel:1'])
|
||||
)
|
||||
const sort = within(header.getByRole('group', { name: 'Sort channels by' }))
|
||||
sort.getByRole('combobox', { name: 'Sort channels by' }).focus()
|
||||
const user = userEvent.setup()
|
||||
await user.tab()
|
||||
expect(sort.getByRole('button', { name: 'Descending' })).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() =>
|
||||
expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3'])
|
||||
)
|
||||
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('the compact more-actions button opens by keyboard and restores focus on Escape', async () => {
|
||||
await mount('card', false)
|
||||
const user = userEvent.setup()
|
||||
const more = within(screen.getByRole('banner')).getByRole('button', {
|
||||
name: 'More actions',
|
||||
})
|
||||
more.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(
|
||||
await screen.findByRole('menuitem', { name: 'Test All Channels' })
|
||||
).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(more).toHaveFocus())
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('sorting a table column updates the header selection and remains active in card view', async () => {
|
||||
await mount('table', false)
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
within(screen.getByRole('table')).getByRole('button', {
|
||||
name: 'Name',
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Asc' }))
|
||||
const header = within(screen.getByRole('banner'))
|
||||
expect(
|
||||
header.getByRole('combobox', { name: 'Sort channels by' })
|
||||
).toHaveTextContent('Name')
|
||||
expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Card view' }))
|
||||
expect(
|
||||
header.getByRole('combobox', { name: 'Sort channels by' })
|
||||
).toHaveTextContent('Name')
|
||||
expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('dropping onto a card that moved into the original slot joins its priority tier', async () => {
|
||||
await mount()
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, clientX: 660, clientY: 130 }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 150 })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).toBeInTheDocument()
|
||||
)
|
||||
// Channel 2 now occupies the last slot that channel 3 originally occupied.
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, clientX: 790, clientY: 150 })
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Join the same priority tier'
|
||||
)
|
||||
)
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
|
||||
)
|
||||
).toHaveTextContent('80')
|
||||
expect(
|
||||
document.querySelector('[data-sortable-id="channel:2"]')
|
||||
).toHaveAttribute('data-priority-drop', 'same')
|
||||
fireEvent.pointerUp(scope, { pointerId: 1, clientX: 790, clientY: 150 })
|
||||
await screen.findByRole('button', { name: 'Undo' })
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([
|
||||
{ id: 3, target_id: 2, position: 'same', revision: 'initial' },
|
||||
])
|
||||
})
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s keeps a held tier preview until Escape cancels it',
|
||||
async (view) => {
|
||||
await mount(view)
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
const source = document.querySelector(
|
||||
'[data-sortable-id="channel:3"]'
|
||||
) as HTMLElement
|
||||
const start =
|
||||
view === 'table'
|
||||
? { clientX: 20, clientY: 250 }
|
||||
: { clientX: 660, clientY: 130 }
|
||||
const target =
|
||||
view === 'table'
|
||||
? { clientX: 20, clientY: 190 }
|
||||
: { clientX: 470, clientY: 150 }
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, ...start }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, ...target })
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Join the same priority tier'
|
||||
)
|
||||
)
|
||||
expect(source.isConnected).toBe(true)
|
||||
expect(source).toHaveStyle({ opacity: '0.2' })
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]'
|
||||
)
|
||||
).toHaveTextContent('80')
|
||||
expect(
|
||||
document.querySelector('[data-sortable-id="channel:2"]')
|
||||
).toHaveAttribute('data-priority-drop', 'same')
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(
|
||||
document.querySelector('[data-priority-drop]')
|
||||
).not.toBeInTheDocument()
|
||||
expect(source.style.opacity).toBe('')
|
||||
expect(source.style.transform).toBe('')
|
||||
expect(requests.filter((request) => !request.preview)).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['table', 'card'] as const)(
|
||||
'%s distinguishes inserting a new tier from joining the target tier before release',
|
||||
async (view) => {
|
||||
await mount(view)
|
||||
pointerGeometry()
|
||||
const scope = screen.getByRole('region', { name: 'Channels' })
|
||||
const start =
|
||||
view === 'table'
|
||||
? { clientX: 20, clientY: 250 }
|
||||
: { clientX: 660, clientY: 130 }
|
||||
const edge =
|
||||
view === 'table'
|
||||
? { clientX: 20, clientY: 165 }
|
||||
: { clientX: 340, clientY: 150 }
|
||||
const center =
|
||||
view === 'table'
|
||||
? { clientX: 20, clientY: 190 }
|
||||
: { clientX: 470, clientY: 150 }
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole('button', { name: 'Reorder channel 3' }),
|
||||
{ pointerId: 1, button: 0, ...start }
|
||||
)
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, ...edge })
|
||||
await screen.findByText('Release to insert a new priority tier')
|
||||
expect(document.querySelector('[data-priority-insertion]')).toBeVisible()
|
||||
expect(
|
||||
document.querySelector('[data-priority-drop="same"]')
|
||||
).not.toBeInTheDocument()
|
||||
await screen.findByText('New priority: 90')
|
||||
fireEvent.pointerMove(scope, { pointerId: 1, ...center })
|
||||
await screen.findByText('Release to join priority 80')
|
||||
expect(
|
||||
screen.queryByText('Release to insert a new priority tier')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
document.querySelector('[data-priority-insertion]')
|
||||
).not.toBeVisible()
|
||||
expect(
|
||||
document.querySelector('[data-sortable-id="channel:2"]')
|
||||
).toHaveAttribute('data-priority-drop', 'same')
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Release to join priority 80')
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -51,7 +51,7 @@ const migratedProviders = [
|
||||
{ type: 50, label: 'Kling', key: 'kling' },
|
||||
{ type: 51, label: 'Jimeng', key: 'jimeng' },
|
||||
{ type: 52, label: 'Vidu', key: 'vidu' },
|
||||
{ type: 54, label: 'DoubaoVideo', key: 'doubao' },
|
||||
{ type: 54, label: 'Doubao', key: 'doubao' },
|
||||
{ type: 55, label: 'Sora', key: 'sora' },
|
||||
]
|
||||
const migratedPlugins = migratedProviders.map((provider) => ({
|
||||
@@ -417,7 +417,7 @@ test('creation searches legacy names and translated names through the replacemen
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
en: { translation: {} },
|
||||
zh: { translation: { DoubaoVideo: '豆包视频' } },
|
||||
zh: { translation: { Doubao: '豆包' } },
|
||||
},
|
||||
})
|
||||
const select = vi.fn()
|
||||
@@ -439,13 +439,13 @@ test('creation searches legacy names and translated names through the replacemen
|
||||
</I18nextProvider>
|
||||
)
|
||||
const search = screen.getByRole('combobox')
|
||||
await user.type(search, 'DoubaoVideo')
|
||||
await user.type(search, 'Doubao')
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
|
||||
).toBeVisible()
|
||||
await act(() => language.changeLanguage('zh'))
|
||||
await user.clear(search)
|
||||
await user.type(search, '豆包视频')
|
||||
await user.type(search, '豆包')
|
||||
await user.click(screen.getByRole('tab', { name: 'Plugins' }))
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Ark Video Plugin doubao' })
|
||||
|
||||
+50
-2
@@ -19,20 +19,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { assert, describe, expect, test } from 'vitest'
|
||||
|
||||
import { Form } from '@/components/ui/form'
|
||||
|
||||
import { CHANNEL_TYPE_NEW_API, CHANNEL_TYPE_SUB2API } from '../../constants'
|
||||
import { CHANNEL_TYPE_ADVANCED_CUSTOM } from '../../lib/advanced-custom'
|
||||
import {
|
||||
buildSettingJSON,
|
||||
CHANNEL_FORM_DEFAULT_VALUES,
|
||||
transformChannelToFormDefaults,
|
||||
transformFormDataToCreatePayload,
|
||||
transformFormDataToUpdatePayload,
|
||||
type ChannelFormValues,
|
||||
} from '../../lib/channel-form'
|
||||
import { channelSchema } from '../../types'
|
||||
import { AstraModelGuardSetting } from '../astra-model-guard-setting'
|
||||
import { ResponsesWebSocketSetting } from '../responses-websocket-setting'
|
||||
|
||||
const supportedChannelTypes = [
|
||||
1,
|
||||
57,
|
||||
CHANNEL_TYPE_ADVANCED_CUSTOM,
|
||||
CHANNEL_TYPE_SUB2API,
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
]
|
||||
|
||||
function SettingsForm(props: {
|
||||
channelType: number
|
||||
disabled?: boolean
|
||||
@@ -130,6 +142,42 @@ describe('Responses WebSocket channel setting', () => {
|
||||
expect(settings.responses_astra_model_guard).toBe(true)
|
||||
}
|
||||
)
|
||||
describe.each(supportedChannelTypes)('channel type %s', (channelType) => {
|
||||
test.each([undefined, false, true])(
|
||||
'preserves setting %s through create, update and reload',
|
||||
(enabled) => {
|
||||
const channel = channelSchema.parse({
|
||||
id: 1,
|
||||
name: 'Test channel',
|
||||
key: '',
|
||||
type: channelType,
|
||||
status: 1,
|
||||
created_time: 0,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
setting: JSON.stringify({ responses_websocket_enabled: enabled }),
|
||||
})
|
||||
const values = transformChannelToFormDefaults(channel)
|
||||
const payloads = [
|
||||
transformFormDataToCreatePayload(values).channel,
|
||||
transformFormDataToUpdatePayload(values, channel.id),
|
||||
]
|
||||
for (const payload of payloads) {
|
||||
assert(typeof payload.setting === 'string')
|
||||
expect(JSON.parse(payload.setting).responses_websocket_enabled).toBe(
|
||||
enabled === true
|
||||
)
|
||||
expect(
|
||||
transformChannelToFormDefaults({
|
||||
...channel,
|
||||
setting: payload.setting,
|
||||
}).responses_websocket_enabled
|
||||
).toBe(enabled === true)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('new channels default to WebSocket disabled', () => {
|
||||
expect(
|
||||
@@ -142,7 +190,7 @@ describe('Responses WebSocket channel setting', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test.each([1, 57])(
|
||||
test.each(supportedChannelTypes)(
|
||||
'channel type %s exposes an accessible switch and saves on and off',
|
||||
async (channelType) => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import type { Channel } from '../../types'
|
||||
import { ChannelsProvider } from '../channels-provider'
|
||||
import { ChannelsTable } from '../channels-table'
|
||||
|
||||
const i18n = createInstance()
|
||||
await i18n.init({
|
||||
lng: 'en',
|
||||
resources: { en: { translation: {} } },
|
||||
initAsync: false,
|
||||
})
|
||||
const clients: QueryClient[] = []
|
||||
|
||||
function channel(name: string): Channel {
|
||||
return {
|
||||
id: 3,
|
||||
type: 1,
|
||||
key: '',
|
||||
status: 1,
|
||||
name,
|
||||
created_time: 0,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
other: '',
|
||||
balance: 0,
|
||||
balance_updated_time: 0,
|
||||
models: 'gpt-4o',
|
||||
group: 'vip',
|
||||
used_quota: 0,
|
||||
other_info: '',
|
||||
remark: '',
|
||||
max_input_tokens: 0,
|
||||
channel_info: {
|
||||
is_multi_key: false,
|
||||
multi_key_size: 0,
|
||||
multi_key_polling_index: 0,
|
||||
multi_key_mode: 'random',
|
||||
},
|
||||
settings: '{}',
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
clients.splice(0).forEach((client) => client.clear())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderChannelsPage(searchGate: () => Promise<void>) {
|
||||
const get = vi.spyOn(api, 'get').mockImplementation(async (url) => {
|
||||
if (url === '/api/channel/search') {
|
||||
await searchGate()
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: { items: [channel('prod')], total: 1, type_counts: {} },
|
||||
},
|
||||
}
|
||||
}
|
||||
return { data: { success: true, data: ['vip'] } }
|
||||
})
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
clients.push(client)
|
||||
const root = createRootRoute()
|
||||
const auth = createRoute({ getParentRoute: () => root, id: '_authenticated' })
|
||||
const channelsRoute = createRoute({
|
||||
getParentRoute: () => auth,
|
||||
path: 'channels/',
|
||||
component: () => (
|
||||
<ChannelsProvider>
|
||||
<ChannelsTable />
|
||||
</ChannelsProvider>
|
||||
),
|
||||
})
|
||||
const initialEntry =
|
||||
'/channels/?filter=prod&group=%5B%22vip%22%5D&status=%5B%22enabled%22%5D&model=gpt-4o'
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([auth.addChildren([channelsRoute])]),
|
||||
history: createMemoryHistory({ initialEntries: [initialEntry] }),
|
||||
})
|
||||
await router.load()
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
)
|
||||
expect((await screen.findAllByText('prod')).length).toBeGreaterThan(0)
|
||||
return get
|
||||
}
|
||||
|
||||
it('refetches the channel list with the current filters and marks Refresh busy while it runs', async () => {
|
||||
let gate = Promise.resolve()
|
||||
const get = await renderChannelsPage(() => gate)
|
||||
const searchCalls = () =>
|
||||
get.mock.calls.filter(([url]) => url === '/api/channel/search')
|
||||
const refresh = screen.getByRole('button', { name: 'Refresh' })
|
||||
expect(searchCalls()).toHaveLength(1)
|
||||
expect(refresh).toHaveAttribute('aria-busy', 'false')
|
||||
|
||||
let release!: () => void
|
||||
gate = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
refresh.focus()
|
||||
await userEvent.setup().keyboard('{Enter}')
|
||||
|
||||
await waitFor(() => expect(searchCalls()).toHaveLength(2))
|
||||
expect(searchCalls()[1][1]?.params).toEqual(searchCalls()[0][1]?.params)
|
||||
expect(refresh).toHaveAttribute('aria-busy', 'true')
|
||||
release()
|
||||
await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'false'))
|
||||
})
|
||||
@@ -251,3 +251,27 @@ test('models published under another name show that name as a badge next to the
|
||||
)
|
||||
expect(screen.queryByLabelText(/Published as o3/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('long model names remain fully labeled and selectable in a container-responsive list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const model = 'gpt-4.1-mini-deployment-with-a-long-region-name-2025-04-14'
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<UpstreamModelSelection
|
||||
models={[model]}
|
||||
selected={[]}
|
||||
existingModels={[]}
|
||||
showChanges={false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
const checkbox = screen.getByRole('checkbox', { name: model })
|
||||
const category = checkbox.closest('[data-slot="collapsible"]')
|
||||
expect(category).toHaveClass('@container')
|
||||
const grid = checkbox.parentElement?.parentElement
|
||||
expect(grid).toHaveClass('@lg:grid-cols-2')
|
||||
expect(grid).not.toHaveClass('sm:grid-cols-2')
|
||||
expect(screen.getByText(model)).toBeVisible()
|
||||
await user.click(checkbox)
|
||||
expect(onChange).toHaveBeenCalledWith([model])
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import { useChannelUpstreamUpdates } from '../../hooks/use-channel-upstream-updates'
|
||||
import { UpstreamUpdateDialog } from '../dialogs/upstream-update-dialog'
|
||||
|
||||
const detectionResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: { add_models: ['gpt-new', 'gpt-extra'], remove_models: ['gpt-old'] },
|
||||
},
|
||||
}
|
||||
const applyResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
added_models: ['gpt-new'],
|
||||
removed_models: ['gpt-old'],
|
||||
remaining_models: ['gpt-extra'],
|
||||
remaining_remove_models: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function UpstreamUpdateHarness(props: { refresh?: () => Promise<void> }) {
|
||||
const upstream = useChannelUpstreamUpdates(
|
||||
props.refresh ?? (() => Promise.resolve())
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => upstream.detectChannelUpdates({ id: 42, name: 'batch' })}
|
||||
>
|
||||
Detect
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
upstream.openModal(
|
||||
{ id: 43, name: 'other channel' },
|
||||
[],
|
||||
['other-old'],
|
||||
'remove'
|
||||
)
|
||||
}
|
||||
>
|
||||
Other channel
|
||||
</Button>
|
||||
<UpstreamUpdateDialog upstream={upstream} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('upstream update preview', () => {
|
||||
test('detecting changes opens a preview without applying them', async () => {
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
|
||||
const { result } = renderHook(() =>
|
||||
useChannelUpstreamUpdates(vi.fn().mockResolvedValue(undefined))
|
||||
)
|
||||
await act(() =>
|
||||
result.current.detectChannelUpdates({ id: 42, name: 'batch' })
|
||||
)
|
||||
expect(result.current.showModal).toBe(true)
|
||||
expect(result.current.addModels).toEqual(['gpt-new', 'gpt-extra'])
|
||||
expect(result.current.removeModels).toEqual(['gpt-old'])
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{ id: 42 },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
test('loading leads to selectable models and cancelling makes no apply request', async () => {
|
||||
let resolve!: (value: typeof detectionResponse) => void
|
||||
const promise = new Promise<typeof detectionResponse>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(promise)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Checking upstream models. Channel models will only change after confirmation.'
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Review selected changes' })
|
||||
).not.toBeInTheDocument()
|
||||
await act(async () => resolve(detectionResponse))
|
||||
expect(screen.getByRole('checkbox', { name: 'gpt-new' })).toBeChecked()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('removals require explicit selection and a reopened preview resets selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Other channel' }))
|
||||
const model = screen.getByRole('checkbox', { name: 'other-old' })
|
||||
expect(model).not.toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
).toBeDisabled()
|
||||
await user.click(model)
|
||||
expect(model).toBeChecked()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Other channel' }))
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'other-old' })
|
||||
).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('review lists the selected changes and applies only after confirmation without ignoring unchecked models', async () => {
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce(detectionResponse)
|
||||
.mockResolvedValueOnce(applyResponse)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'gpt-extra' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Remove Models (1)' }))
|
||||
const removal = screen.getByRole('checkbox', { name: 'gpt-old' })
|
||||
expect(removal).not.toBeChecked()
|
||||
await user.click(removal)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
)
|
||||
const confirmation = screen.getByRole('alertdialog')
|
||||
expect(within(confirmation).getByText('gpt-new')).toBeVisible()
|
||||
expect(within(confirmation).getByText('gpt-old')).toBeVisible()
|
||||
expect(
|
||||
within(confirmation).queryByText('gpt-extra')
|
||||
).not.toBeInTheDocument()
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
await user.click(
|
||||
within(confirmation).getByRole('button', {
|
||||
name: 'Apply selected changes',
|
||||
})
|
||||
)
|
||||
expect(post).toHaveBeenLastCalledWith(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: 42,
|
||||
add_models: ['gpt-new'],
|
||||
remove_models: ['gpt-old'],
|
||||
ignore_models: [],
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
const results = await screen.findByRole('dialog', {
|
||||
name: 'Update results',
|
||||
})
|
||||
expect(within(results).getByText('#42 batch')).toBeVisible()
|
||||
expect(
|
||||
within(
|
||||
within(results).getByRole('region', { name: 'Added models' })
|
||||
).getByText('gpt-new')
|
||||
).toBeVisible()
|
||||
expect(
|
||||
within(
|
||||
within(results).getByRole('region', { name: 'Removed models' })
|
||||
).getByText('gpt-old')
|
||||
).toBeVisible()
|
||||
expect(
|
||||
within(
|
||||
within(results).getByRole('region', { name: 'Still pending addition' })
|
||||
).getByText('gpt-extra')
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Apply selected changes' })
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(results).getAllByRole('button', { name: 'Close' })
|
||||
).toContain(document.activeElement)
|
||||
})
|
||||
})
|
||||
|
||||
test('cancelling the final confirmation returns to the same selection without saving', async () => {
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'gpt-extra' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
)
|
||||
await user.click(
|
||||
within(screen.getByRole('alertdialog')).getByRole('button', {
|
||||
name: 'Cancel',
|
||||
})
|
||||
)
|
||||
expect(screen.getByRole('checkbox', { name: 'gpt-new' })).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'gpt-extra' })
|
||||
).not.toBeChecked()
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('detection failures remain visible and retry can show an empty preview', async () => {
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce({
|
||||
data: { success: false, message: 'Upstream unavailable' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { success: true, data: { add_models: [], remove_models: [] } },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
expect(await screen.findByText('Upstream unavailable')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'No processable upstream model updates for this channel'
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
).toBeDisabled()
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('long labels can wrap without a fixed tab-list height', async () => {
|
||||
vi.spyOn(api, 'post').mockResolvedValue(detectionResponse)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
expect(await screen.findByRole('tablist')).toHaveClass(
|
||||
'group-data-horizontal/tabs:h-auto'
|
||||
)
|
||||
for (const tab of screen.getAllByRole('tab')) {
|
||||
expect(tab).toHaveClass('h-auto', 'whitespace-normal')
|
||||
}
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
).toHaveClass('whitespace-normal')
|
||||
})
|
||||
|
||||
test('late detection responses cannot replace another channel preview', async () => {
|
||||
let resolve!: (value: typeof detectionResponse) => void
|
||||
const promise = new Promise<typeof detectionResponse>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
vi.spyOn(api, 'post').mockReturnValue(promise)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'Other channel' }))
|
||||
await act(async () => resolve(detectionResponse))
|
||||
expect(screen.getByText('#43 other channel')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'other-old' })
|
||||
).not.toBeChecked()
|
||||
expect(screen.queryByText('gpt-new')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('pending apply cannot be dismissed or submitted twice and results use the server response even if refresh fails', async () => {
|
||||
let resolve!: (value: typeof applyResponse) => void
|
||||
const promise = new Promise<typeof applyResponse>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce(detectionResponse)
|
||||
.mockReturnValueOnce(promise)
|
||||
const refresh = vi.fn().mockRejectedValue(new Error('List refresh failed'))
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness refresh={refresh} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Review selected changes' })
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply selected changes' })
|
||||
)
|
||||
const confirmation = screen.getByRole('alertdialog')
|
||||
expect(
|
||||
within(confirmation).getByRole('button', { name: 'Applying...' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(confirmation).getByRole('button', { name: 'Cancel' })
|
||||
).toBeDisabled()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('alertdialog')).toBeVisible()
|
||||
await act(async () => resolve(applyResponse))
|
||||
const results = await screen.findByRole('dialog', {
|
||||
name: 'Update results',
|
||||
})
|
||||
const added = within(results).getByRole('region', { name: 'Added models' })
|
||||
expect(within(added).getByText('gpt-new')).toBeVisible()
|
||||
expect(within(added).queryByText('gpt-extra')).not.toBeInTheDocument()
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('failed apply keeps its error visible and requires a fresh preview before resubmission', async () => {
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce(detectionResponse)
|
||||
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||
.mockResolvedValueOnce(detectionResponse)
|
||||
const user = userEvent.setup()
|
||||
render(<UpstreamUpdateHarness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Detect' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'Review selected changes' })
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply selected changes' })
|
||||
)
|
||||
expect(await screen.findByText('Connection lost')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Update results' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh preview' }))
|
||||
expect(
|
||||
await screen.findByRole('checkbox', { name: 'gpt-new' })
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Review selected changes' })
|
||||
).toBeEnabled()
|
||||
expect(post).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StatusBadgeTypeContext } from '@/components/status-badge'
|
||||
|
||||
import { CHANNEL_STATUS } from '../constants'
|
||||
import { isTagAggregateRow, parseGroupsList } from '../lib'
|
||||
@@ -42,9 +42,11 @@ const SENSITIVE_MASK = '••••'
|
||||
function ChannelCardComponent({
|
||||
row,
|
||||
isSelected,
|
||||
reorderEnabled = false,
|
||||
}: {
|
||||
row: Row<Channel>
|
||||
isSelected: boolean
|
||||
reorderEnabled?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { sensitiveVisible } = useChannels()
|
||||
@@ -59,14 +61,9 @@ function ChannelCardComponent({
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
balance: t('Used / Remaining'),
|
||||
response_time: t('Response'),
|
||||
test_time: t('Last Tested'),
|
||||
}
|
||||
|
||||
const groups = parseGroupsList(row.original.group ?? '')
|
||||
|
||||
const reorderCell = reorderEnabled ? renderCell('reorder') : null
|
||||
const selectCell = renderCell('select')
|
||||
const typeCell = renderCell('type')
|
||||
const nameCell = renderCell('name')
|
||||
@@ -92,11 +89,12 @@ function ChannelCardComponent({
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
className='flex min-w-0 flex-col gap-3'
|
||||
>
|
||||
{/* Row 1: selection + type, with status badge + actions menu */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{reorderCell}
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
@@ -108,12 +106,11 @@ function ChannelCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: left column (id/name + balance) paired with a right-aligned
|
||||
column (priority/weight + response/test time). */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
{/* Left column */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{/* Both rows share their columns: identity/balance on the left,
|
||||
priority/response and weight/last tested on the right. */}
|
||||
<StatusBadgeTypeContext.Provider value='text'>
|
||||
<div className='grid grid-cols-[minmax(0,1fr)_auto_auto] items-start gap-3'>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
@@ -121,45 +118,48 @@ function ChannelCardComponent({
|
||||
)}
|
||||
{nameCell}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>
|
||||
{fieldLabels.balance}
|
||||
<div
|
||||
data-column-id='priority'
|
||||
className='flex min-w-0 flex-col gap-1'
|
||||
>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
{priorityCell}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-1'>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
{weightCell}
|
||||
</div>
|
||||
<dl className='col-span-3 grid grid-cols-subgrid gap-y-1'>
|
||||
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
|
||||
<dt className={labelClass}>{t('Used / Remaining')}</dt>
|
||||
<dd className='min-w-0 text-sm tabular-nums [&_[data-slot=status-badge]]:!ml-0 [&>div]:ml-0 [&>div]:flex-wrap [&>div]:gap-x-3'>
|
||||
{balanceCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{balanceCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
|
||||
<dt className={labelClass}>{t('Response')}</dt>
|
||||
<dd className='min-w-0 text-sm tabular-nums [&_[data-slot=status-badge]]:!ml-0'>
|
||||
{responseCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className='row-span-2 grid min-w-0 grid-rows-subgrid'>
|
||||
<dt className={labelClass}>{t('Last Tested')}</dt>
|
||||
<dd className='min-w-0 text-sm [&_[data-slot=status-badge]]:!ml-0'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</StatusBadgeTypeContext.Provider>
|
||||
|
||||
{/* Right column (sits on the right, content left-aligned). A single
|
||||
grid with content-sized columns keeps Priority/Weight and
|
||||
Response/Last Tested aligned without wasting horizontal space. */}
|
||||
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
<div className='flex justify-start'>{priorityCell}</div>
|
||||
<div className='flex justify-start'>{weightCell}</div>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.response_time}
|
||||
</span>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.test_time}
|
||||
</span>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||
{/* Groups retain their compact, full-width footer. */}
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
<div className='-ml-1.5 flex min-w-0 flex-wrap gap-1'>
|
||||
{groups.map((g) => (
|
||||
<GroupBadge
|
||||
key={g}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createContext, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
|
||||
type PriorityControls = {
|
||||
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
|
||||
start: (event: PointerEvent<HTMLButtonElement>, id: number) => void
|
||||
keyDown: (event: KeyboardEvent<HTMLButtonElement>, id: number) => void
|
||||
}
|
||||
|
||||
export const ChannelPriorityContext = createContext<PriorityControls | null>(
|
||||
null
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowDown, ArrowUp, GripVertical, Undo2 } from 'lucide-react'
|
||||
import { useContext, useState, useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { DataTableViewMode } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
|
||||
import { CHANNEL_PRIORITY_GUIDE_KEYS } from '../constants'
|
||||
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
|
||||
import type { ChannelSortBy } from '../types'
|
||||
import { ChannelPriorityContext } from './channel-priority-context'
|
||||
import { ChannelPriorityGuide } from './channel-priority-guide'
|
||||
|
||||
export function ChannelPriorityHandle(props: { id: number }) {
|
||||
const controls = useContext(ChannelPriorityContext)
|
||||
const { t } = useTranslation()
|
||||
if (!controls) return null
|
||||
let hint = t(
|
||||
'Drag to change priority. Use arrow keys to move, Escape to cancel.'
|
||||
)
|
||||
if (controls.reason === 'sort') hint = t('Sort by priority to drag channels')
|
||||
if (controls.reason === 'tag') {
|
||||
hint = t('Disable tag grouping to drag channels')
|
||||
}
|
||||
if (controls.reason === 'permission') {
|
||||
hint = t('You do not have permission to edit channels')
|
||||
}
|
||||
if (controls.reason === 'busy') hint = t('Saving...')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className='inline-flex shrink-0' />}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='text-muted-foreground cursor-grab touch-none active:cursor-grabbing'
|
||||
disabled={!!controls.reason}
|
||||
aria-label={
|
||||
controls.reason === 'tag'
|
||||
? hint
|
||||
: t('Reorder channel {{id}}', { id: props.id })
|
||||
}
|
||||
onPointerDown={(event) => controls.start(event, props.id)}
|
||||
onKeyDown={(event) => controls.keyDown(event, props.id)}
|
||||
>
|
||||
<GripVertical className='size-4' aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelSortControl(props: {
|
||||
field: ChannelSortBy
|
||||
desc: boolean
|
||||
disabled?: boolean
|
||||
onChange: (field: ChannelSortBy, desc: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const options = [
|
||||
{ value: 'priority', label: t('Priority') },
|
||||
{ value: 'id', label: t('ID') },
|
||||
{ value: 'name', label: t('Name') },
|
||||
{ value: 'balance', label: t('Balance') },
|
||||
{ value: 'response_time', label: t('Response') },
|
||||
{ value: 'test_time', label: t('Last Tested') },
|
||||
]
|
||||
return (
|
||||
<ButtonGroup aria-label={t('Sort channels by')} className='shrink-0'>
|
||||
<Select
|
||||
value={props.field}
|
||||
disabled={props.disabled}
|
||||
onValueChange={(value) => {
|
||||
if (value) props.onChange(value as ChannelSortBy, props.desc)
|
||||
}}
|
||||
items={options}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('Sort channels by')}
|
||||
className='bg-background min-w-24'
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
disabled={props.disabled}
|
||||
aria-label={props.desc ? t('Descending') : t('Ascending')}
|
||||
title={props.desc ? t('Descending') : t('Ascending')}
|
||||
onClick={() => props.onChange(props.field, !props.desc)}
|
||||
>
|
||||
{props.desc ? (
|
||||
<ArrowDown aria-hidden className='size-4' />
|
||||
) : (
|
||||
<ArrowUp aria-hidden className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelPriorityModeToggle(props: {
|
||||
active: boolean
|
||||
viewMode: DataTableViewMode
|
||||
reason: 'sort' | 'tag' | 'permission' | 'busy' | null
|
||||
onChange: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [guideSeen, setGuideSeen] = useState<
|
||||
Partial<Record<DataTableViewMode, boolean>>
|
||||
>(() => {
|
||||
try {
|
||||
return {
|
||||
card: localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.card) === 'seen',
|
||||
table:
|
||||
localStorage.getItem(CHANNEL_PRIORITY_GUIDE_KEYS.table) === 'seen',
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
const handleGuideOpenChange = (open: boolean) => {
|
||||
setGuideOpen(open)
|
||||
if (open) return
|
||||
setGuideSeen((seen) => ({ ...seen, [props.viewMode]: true }))
|
||||
try {
|
||||
localStorage.setItem(CHANNEL_PRIORITY_GUIDE_KEYS[props.viewMode], 'seen')
|
||||
} catch {
|
||||
// Remember dismissal for this visit when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
let hint = t('Drag mode')
|
||||
if (props.reason === 'sort') hint = t('Sort by priority to drag channels')
|
||||
if (props.reason === 'tag') hint = t('Disable tag grouping to drag channels')
|
||||
if (props.reason === 'permission') {
|
||||
hint = t('You do not have permission to edit channels')
|
||||
}
|
||||
if (props.reason === 'busy') hint = t('Saving...')
|
||||
return (
|
||||
<ButtonGroup aria-label={t('Drag mode')} className='shrink-0'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className='inline-flex [&>button]:rounded-r-none' />}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
className='text-muted-foreground aria-pressed:border-primary/30 aria-pressed:bg-primary/10 aria-pressed:text-primary aria-pressed:hover:bg-primary/15'
|
||||
aria-pressed={props.active}
|
||||
disabled={!!props.reason}
|
||||
onClick={() => {
|
||||
props.onChange()
|
||||
if (!props.active && !guideSeen[props.viewMode]) {
|
||||
setGuideOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GripVertical className='size-4' aria-hidden />
|
||||
{props.active ? t('Finish reordering') : t('Drag mode')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ChannelPriorityGuide
|
||||
open={guideOpen}
|
||||
viewMode={props.viewMode}
|
||||
onOpenChange={handleGuideOpenChange}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelPriorityStatus(props: {
|
||||
order: ReturnType<typeof useChannelPriorityOrder>
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const order = props.order
|
||||
const hover = useSyncExternalStore(
|
||||
order.previewStore.subscribe,
|
||||
order.previewStore.getSnapshot
|
||||
)
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
let message = ''
|
||||
if (order.saving) message = t('Saving...')
|
||||
else if (hover.preparing) message = t('Calculating priority...')
|
||||
else if (hover.preview && hover.drop) {
|
||||
const value =
|
||||
hover.preview.items.find((item) => item.id === hover.drop?.id)
|
||||
?.priority ?? 0
|
||||
const visible = new Set(order.items.map((item) => item.id))
|
||||
const hidden = hover.preview.changes.filter(
|
||||
(item) => !visible.has(item.id)
|
||||
).length
|
||||
message = t('New priority: {{priority}} · {{count}} channels affected', {
|
||||
priority: formatNumber(value, locale),
|
||||
count: hover.preview.changes.length,
|
||||
})
|
||||
if (hidden) {
|
||||
message += ` · ${t('{{count}} outside this view', { count: hidden })}`
|
||||
}
|
||||
if (hover.drop.position === 'same') {
|
||||
message += ` · ${t('Join the same priority tier')}`
|
||||
} else {
|
||||
message += ` · ${t('Release to insert a new priority tier')}`
|
||||
}
|
||||
} else if (order.active) {
|
||||
message = t(
|
||||
'Drop between tiers to change priority, or in the center to join a tier'
|
||||
)
|
||||
}
|
||||
if (!message && !order.undo?.changes.length) return null
|
||||
return (
|
||||
<div className='text-muted-foreground flex shrink-0 flex-wrap items-center justify-between gap-2 px-1 text-xs'>
|
||||
<span role='status' aria-live='polite'>
|
||||
{message || t('Channel priorities saved')}
|
||||
</span>
|
||||
{!!order.undo?.changes.length && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
disabled={order.active || order.saving}
|
||||
onClick={() => void order.restore()}
|
||||
>
|
||||
<Undo2 className='size-3.5' aria-hidden />
|
||||
{t('Undo')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { GripVertical, MousePointer2 } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { DataTableViewMode } from '@/components/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const frames = [
|
||||
{ at: 0, phase: 'ready', lifted: false },
|
||||
{ at: 700, phase: 'lift', lifted: true },
|
||||
{ at: 1500, phase: 'target', lifted: true },
|
||||
{ at: 3000, phase: 'done', lifted: false },
|
||||
] as const
|
||||
|
||||
export function ChannelPriorityDemo(props: {
|
||||
viewMode: DataTableViewMode
|
||||
mode: 'same' | 'insert'
|
||||
playing: boolean
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')
|
||||
const [frame, setFrame] = useState(0)
|
||||
const elapsed = useRef(0)
|
||||
const current = frames[reduced ? frames.length - 1 : frame]
|
||||
const same = props.mode === 'same'
|
||||
const reached = current.phase === 'target' || current.phase === 'done'
|
||||
const between = !same && reached
|
||||
const result = same ? 80 : 90
|
||||
const card = props.viewMode === 'card'
|
||||
const reorder = between && (card || current.phase === 'done')
|
||||
const order = reorder ? ['A', 'C', 'B'] : ['A', 'B', 'C']
|
||||
let pointerY = 150
|
||||
if (reached) pointerY = same ? 92 : 66
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
let instruction = t('Drag the handle to move a channel.')
|
||||
if (current.phase === 'target') {
|
||||
instruction = same
|
||||
? t('Release to join priority {{priority}}', {
|
||||
priority: formatNumber(result, locale),
|
||||
})
|
||||
: t('Release to insert a new priority tier')
|
||||
} else if (current.phase === 'done') {
|
||||
instruction = t('New priority: {{priority}}', {
|
||||
priority: formatNumber(result, locale),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced || !props.playing) return
|
||||
const started = performance.now()
|
||||
const timers = frames
|
||||
.map((item, index) => ({ ...item, index }))
|
||||
.filter((item) => item.at > elapsed.current)
|
||||
.map((item) =>
|
||||
window.setTimeout(() => setFrame(item.index), item.at - elapsed.current)
|
||||
)
|
||||
// Keep the completed result visible before starting the next named demo.
|
||||
timers.push(window.setTimeout(props.onComplete, 5200 - elapsed.current))
|
||||
return () => {
|
||||
elapsed.current += performance.now() - started
|
||||
timers.forEach(window.clearTimeout)
|
||||
}
|
||||
}, [props.playing, props.onComplete, reduced])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 grid gap-1.5 rounded-lg border p-3',
|
||||
same
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-amber-500/40 bg-amber-500/5'
|
||||
)}
|
||||
>
|
||||
<p className='text-sm font-medium'>
|
||||
{same
|
||||
? t('Drop in the center to use the same priority.')
|
||||
: t('Drop at an edge to create a different priority.')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs tabular-nums'>
|
||||
{t('Priority')} {formatNumber(0, locale)} →{' '}
|
||||
{formatNumber(result, locale)}
|
||||
</p>
|
||||
</div>
|
||||
<ol
|
||||
aria-label={card ? t('Card view') : t('Table view')}
|
||||
className={cn(
|
||||
'bg-muted/30 relative m-0 list-none overflow-hidden rounded-xl border p-0',
|
||||
card ? 'h-60' : 'h-52'
|
||||
)}
|
||||
>
|
||||
{!card && current.lifted && (
|
||||
<>
|
||||
<li
|
||||
aria-hidden
|
||||
role='presentation'
|
||||
className='absolute inset-x-3 top-35 h-13 rounded-lg border border-dashed opacity-30'
|
||||
/>
|
||||
<motion.li
|
||||
aria-hidden
|
||||
role='presentation'
|
||||
animate={{ y: pointerY }}
|
||||
transition={{ duration: reduced ? 0 : 0.6 }}
|
||||
className='absolute top-3 left-6 z-30'
|
||||
>
|
||||
<MousePointer2 className='fill-primary text-primary-foreground size-5 drop-shadow' />
|
||||
</motion.li>
|
||||
</>
|
||||
)}
|
||||
{between && (
|
||||
<li
|
||||
aria-hidden
|
||||
role='presentation'
|
||||
className={cn(
|
||||
'absolute z-20 rounded-full bg-amber-500',
|
||||
card
|
||||
? 'top-3 left-1/2 h-24 w-0.5'
|
||||
: 'inset-x-3 top-[4.5rem] h-0.5'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{order.map((name, index) => {
|
||||
const lifted = name === 'C' && current.lifted
|
||||
const peer = same && reached && name !== 'A'
|
||||
const target = name === 'B' && current.phase !== 'done'
|
||||
let priority = name === 'A' ? 100 : 80
|
||||
if (name === 'C') priority = reached ? result : 0
|
||||
let y = index * 64
|
||||
if (lifted && !card) y = reached ? 20 : 96
|
||||
let x: number | string = lifted ? 6 : 0
|
||||
if (card) {
|
||||
let column = index % 2
|
||||
y = Math.floor(index / 2) * 108
|
||||
if (lifted && same && reached) {
|
||||
column = 1
|
||||
y = 24
|
||||
}
|
||||
x = `calc(${column * 100}% + ${column * 12 + (lifted ? 4 : 0)}px)`
|
||||
}
|
||||
let layout = 'right-3 h-13 items-center gap-2 px-3'
|
||||
if (card) {
|
||||
layout =
|
||||
'h-24 w-[calc(50%-1.125rem)] flex-col items-start justify-between p-3'
|
||||
} else if (lifted) {
|
||||
layout = 'right-6 left-auto h-9 w-[60%] items-center gap-2 px-2'
|
||||
}
|
||||
return (
|
||||
<motion.li
|
||||
key={name}
|
||||
initial={false}
|
||||
animate={{ y, x, scale: lifted ? 1.015 : 1 }}
|
||||
transition={{
|
||||
duration: reduced ? 0 : 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
className={cn(
|
||||
'bg-background absolute top-3 left-3 flex min-w-0 rounded-lg border',
|
||||
layout,
|
||||
peer && 'border-primary bg-primary/5',
|
||||
target && same && 'border-primary',
|
||||
target && !same && 'border-amber-500',
|
||||
lifted && 'bg-background z-10 shadow-lg',
|
||||
lifted && same && 'border-primary',
|
||||
name === 'C' &&
|
||||
!same &&
|
||||
(lifted || reached) &&
|
||||
'border-amber-500'
|
||||
)}
|
||||
>
|
||||
{target && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute',
|
||||
same
|
||||
? 'border-primary/60 bg-primary/10'
|
||||
: 'border-amber-500/60 bg-amber-500/10',
|
||||
card &&
|
||||
same &&
|
||||
'inset-y-0 inset-x-6 border-x border-dashed',
|
||||
card &&
|
||||
!same &&
|
||||
'inset-y-0 left-0 w-6 border-r border-dashed',
|
||||
!card &&
|
||||
same &&
|
||||
'inset-x-0 inset-y-2 border-y border-dashed',
|
||||
!card &&
|
||||
!same &&
|
||||
'inset-x-0 top-0 h-2 border-b border-dashed'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className='relative flex min-w-0 items-center gap-2'>
|
||||
<span
|
||||
className='text-muted-foreground relative shrink-0'
|
||||
aria-hidden
|
||||
>
|
||||
<GripVertical
|
||||
className={cn('size-4', lifted && 'text-primary')}
|
||||
/>
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-sm font-medium'>
|
||||
{t('Channel {{name}}', { name })}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'relative shrink-0 tabular-nums',
|
||||
!card && 'ml-auto',
|
||||
peer && 'border-primary/30 text-primary'
|
||||
)}
|
||||
>
|
||||
{t('Priority')} {formatNumber(priority, locale)}
|
||||
</Badge>
|
||||
{lifted && card && (
|
||||
<MousePointer2
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'fill-primary text-primary-foreground absolute top-1/2 size-5 drop-shadow',
|
||||
same && reached ? 'left-1/2' : '-left-2'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
<p
|
||||
role='status'
|
||||
className='text-muted-foreground mt-3 min-h-5 text-sm leading-5'
|
||||
>
|
||||
{instruction}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowDownToLine, Layers } from 'lucide-react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
export function ChannelPriorityDropHint(props: {
|
||||
container: HTMLElement | null
|
||||
order: ReturnType<typeof useChannelPriorityOrder>
|
||||
channels: Channel[]
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const hover = useSyncExternalStore(
|
||||
props.order.previewStore.subscribe,
|
||||
props.order.previewStore.getSnapshot
|
||||
)
|
||||
if (!props.container || !hover.drop) return null
|
||||
const joining = hover.drop.position === 'same'
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const targetPriority =
|
||||
props.channels.find((item) => item.id === hover.drop?.target_id)
|
||||
?.priority ?? 0
|
||||
const priority =
|
||||
hover.preview?.items.find((item) => item.id === hover.drop?.id)?.priority ??
|
||||
targetPriority
|
||||
let detail = t('Use the same priority as the highlighted channels')
|
||||
if (!joining) {
|
||||
detail = hover.preview
|
||||
? t('New priority: {{priority}}', {
|
||||
priority: formatNumber(priority, locale),
|
||||
})
|
||||
: t('Calculating priority...')
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
data-priority-hint
|
||||
className={cn(
|
||||
'text-popover-foreground p-3',
|
||||
props.container.dataset.priorityView !== 'table' &&
|
||||
'bg-popover rounded-xl border shadow-xl'
|
||||
)}
|
||||
>
|
||||
<Badge
|
||||
variant={joining ? 'default' : 'warning'}
|
||||
className='h-auto min-h-7 w-full justify-start gap-2 rounded-lg px-2.5 py-1.5 text-sm leading-snug whitespace-normal'
|
||||
>
|
||||
{joining ? (
|
||||
<Layers aria-hidden className='shrink-0' />
|
||||
) : (
|
||||
<ArrowDownToLine aria-hidden className='shrink-0' />
|
||||
)}
|
||||
<span>
|
||||
{joining
|
||||
? t('Release to join priority {{priority}}', {
|
||||
priority: formatNumber(priority, locale),
|
||||
})
|
||||
: t('Release to insert a new priority tier')}
|
||||
</span>
|
||||
</Badge>
|
||||
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
|
||||
{detail}
|
||||
</p>
|
||||
</div>,
|
||||
props.container
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CircleHelp,
|
||||
Layers,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { DataTableViewMode } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
|
||||
import { ChannelPriorityDemo } from './channel-priority-demo'
|
||||
|
||||
export function ChannelPriorityGuide(props: {
|
||||
open: boolean
|
||||
viewMode: DataTableViewMode
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [playback, setPlayback] = useState(0)
|
||||
const [demo, setDemo] = useState<'same' | 'insert'>('same')
|
||||
const [playing, setPlaying] = useState(true)
|
||||
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')
|
||||
const nextDemo = useCallback(() => {
|
||||
setDemo((mode) => (mode === 'same' ? 'insert' : 'same'))
|
||||
}, [])
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setDemo('same')
|
||||
setPlaying(true)
|
||||
}
|
||||
props.onOpenChange(open)
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t('Drag to set channel priority')}
|
||||
description={t(
|
||||
'Move channels into a priority tier or between tiers. Priority values update automatically.'
|
||||
)}
|
||||
contentClassName='sm:max-w-lg'
|
||||
headerClassName='pr-5'
|
||||
trigger={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='text-muted-foreground'
|
||||
aria-label={t('Drag mode help')}
|
||||
title={t('Drag mode help')}
|
||||
>
|
||||
<CircleHelp className='size-4' aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
footerClassName='flex-row items-center justify-between sm:justify-between'
|
||||
footer={
|
||||
<>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
setDemo('same')
|
||||
setPlaying(true)
|
||||
setPlayback((value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<RotateCcw className='size-4' aria-hidden />
|
||||
{t('Replay drag demo')}
|
||||
</Button>
|
||||
{!reduced && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label={
|
||||
playing ? t('Pause drag demo') : t('Resume drag demo')
|
||||
}
|
||||
title={playing ? t('Pause drag demo') : t('Resume drag demo')}
|
||||
onClick={() => setPlaying((value) => !value)}
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className='size-4' aria-hidden />
|
||||
) : (
|
||||
<Play className='size-4' aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type='button' onClick={() => handleOpenChange(false)}>
|
||||
{t('Got it')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={demo}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'same' || value === 'insert') setDemo(value)
|
||||
}}
|
||||
className='gap-3'
|
||||
>
|
||||
<TabsList
|
||||
aria-label={t('Drag mode help')}
|
||||
className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'
|
||||
>
|
||||
<TabsTrigger
|
||||
value='same'
|
||||
className='data-active:text-primary h-auto min-h-9 px-2 py-2 text-xs whitespace-normal'
|
||||
>
|
||||
<Layers className='size-4 shrink-0' aria-hidden />
|
||||
<span>{t('Join the same priority tier')}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='insert'
|
||||
className='h-auto min-h-9 px-2 py-2 text-xs whitespace-normal data-active:text-amber-700 dark:data-active:text-amber-400'
|
||||
>
|
||||
<ArrowDownToLine className='size-4 shrink-0' aria-hidden />
|
||||
<span>{t('Insert a new priority tier')}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{(['same', 'insert'] as const).map((mode) => (
|
||||
<TabsContent key={mode} value={mode}>
|
||||
{props.open && demo === mode && (
|
||||
<ChannelPriorityDemo
|
||||
key={`${props.viewMode}:${mode}:${playback}`}
|
||||
viewMode={props.viewMode}
|
||||
mode={mode}
|
||||
playing={playing}
|
||||
onComplete={nextDemo}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<p className='text-muted-foreground mt-3 border-t pt-3 text-xs leading-5'>
|
||||
{t('Changes save on drop. You can undo the last move.')}
|
||||
</p>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,10 @@ import {
|
||||
} from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import {
|
||||
createServerError,
|
||||
translateServerText,
|
||||
} from '@/lib/server-error-message'
|
||||
import { truncateText } from '@/lib/utils'
|
||||
|
||||
import { getCodexUsage, updateChannelBalance } from '../api'
|
||||
@@ -1023,8 +1026,9 @@ export function useChannelsColumns(
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='space-y-1 text-xs'>
|
||||
{statusReason && (
|
||||
<div>
|
||||
{t('Reason:')} {statusReason}
|
||||
<div className='wrap-anywhere'>
|
||||
{t('Reason:')}{' '}
|
||||
{translateServerText(t, statusReason)}
|
||||
</div>
|
||||
)}
|
||||
{statusTime && (
|
||||
|
||||
@@ -106,15 +106,7 @@ export function ChannelsDialogs() {
|
||||
/>
|
||||
|
||||
{/* Upstream Model Update Dialog */}
|
||||
<UpstreamUpdateDialog
|
||||
open={upstream.showModal}
|
||||
addModels={upstream.addModels}
|
||||
removeModels={upstream.removeModels}
|
||||
preferredTab={upstream.preferredTab}
|
||||
confirmLoading={upstream.applyLoading}
|
||||
onConfirm={upstream.applyUpdates}
|
||||
onCancel={upstream.closeModal}
|
||||
/>
|
||||
<UpstreamUpdateDialog upstream={upstream} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
TestTube,
|
||||
DollarSign,
|
||||
ListChecks,
|
||||
SortAsc,
|
||||
RefreshCw,
|
||||
ArrowUpFromLine,
|
||||
} from 'lucide-react'
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -64,19 +65,25 @@ import {
|
||||
handleTestAllChannels,
|
||||
handleUpdateAllBalances,
|
||||
} from '../lib'
|
||||
import type { ChannelSortBy } from '../types'
|
||||
import { ChannelSortControl } from './channel-priority-controls'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
|
||||
export function ChannelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = route.useNavigate()
|
||||
const {
|
||||
setOpen,
|
||||
setCurrentRow,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
sorting,
|
||||
setSorting,
|
||||
batchMode,
|
||||
setBatchMode,
|
||||
setPriorityActionContainer,
|
||||
upstream,
|
||||
} = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -95,57 +102,60 @@ export function ChannelsPrimaryButtons() {
|
||||
setEnableTagMode(checked)
|
||||
}
|
||||
|
||||
const handleIdSortToggle = (checked: boolean) => {
|
||||
localStorage.setItem('channels-id-sort', String(checked))
|
||||
setIdSort(checked)
|
||||
}
|
||||
|
||||
const handleBatchModeToggle = (checked: boolean) => {
|
||||
setBatchMode(checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div ref={setPriorityActionContainer} className='empty:hidden' />
|
||||
{/* Desktop: Toggle switches visible */}
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<ListChecks className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='channel-batch-mode'
|
||||
className='cursor-pointer text-sm'
|
||||
>
|
||||
{t('Batch Operations')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='channel-batch-mode'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
/>
|
||||
<div className='bg-muted/40 hidden h-8 shrink-0 items-center gap-2.5 rounded-lg border px-2.5 sm:flex'>
|
||||
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
|
||||
<Label
|
||||
htmlFor='channel-batch-mode'
|
||||
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
|
||||
>
|
||||
<ListChecks className='size-3.5' aria-hidden />
|
||||
{t('Batch Operations')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='channel-batch-mode'
|
||||
size='sm'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
/>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='my-2' />
|
||||
<div className='text-muted-foreground has-data-checked:text-foreground flex items-center gap-2'>
|
||||
<Label
|
||||
htmlFor='tag-mode'
|
||||
className='cursor-pointer gap-1.5 text-xs whitespace-nowrap'
|
||||
>
|
||||
<Tags className='size-3.5' aria-hidden />
|
||||
{t('Tag Mode')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='tag-mode'
|
||||
size='sm'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<Tags className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
|
||||
{t('Tag Mode')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='tag-mode'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<SortAsc className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
|
||||
{t('Sort by ID')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='id-sort'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
/>
|
||||
</div>
|
||||
<ChannelSortControl
|
||||
field={(sorting[0]?.id ?? 'priority') as ChannelSortBy}
|
||||
desc={sorting[0]?.desc ?? true}
|
||||
onChange={(field, desc) => {
|
||||
setSorting([{ id: field, desc }])
|
||||
void navigate({
|
||||
search: (previous) => ({ ...previous, page: 1 }),
|
||||
replace: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Tooltip>
|
||||
@@ -156,7 +166,6 @@ export function ChannelsPrimaryButtons() {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
@@ -173,8 +182,11 @@ export function ChannelsPrimaryButtons() {
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant='outline' size='icon' />}
|
||||
aria-label={t('More actions')}
|
||||
>
|
||||
<MoreHorizontal className='size-4' aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
{/* Mobile-only: toggle switches */}
|
||||
@@ -196,15 +208,6 @@ export function ChannelsPrimaryButtons() {
|
||||
{t('Tag Mode')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
>
|
||||
<SortAsc className='mr-2 h-4 w-4' />
|
||||
{t('Sort by ID')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuSeparator className='sm:hidden' />
|
||||
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -18,12 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { OnChangeFn, SortingState } from '@tanstack/react-table'
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
|
||||
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
|
||||
@@ -59,12 +61,14 @@ type ChannelsContextType = {
|
||||
setCurrentTag: (tag: string | null) => void
|
||||
enableTagMode: boolean
|
||||
setEnableTagMode: (enabled: boolean) => void
|
||||
idSort: boolean
|
||||
setIdSort: (enabled: boolean) => void
|
||||
sorting: SortingState
|
||||
setSorting: OnChangeFn<SortingState>
|
||||
batchMode: boolean
|
||||
setBatchMode: (enabled: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
priorityActionContainer: HTMLDivElement | null
|
||||
setPriorityActionContainer: (container: HTMLDivElement | null) => void
|
||||
upstream: UpstreamUpdateState
|
||||
}
|
||||
|
||||
@@ -87,11 +91,22 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [enableTagMode, setEnableTagMode] = useState(() => {
|
||||
return localStorage.getItem('enable-tag-mode') === 'true'
|
||||
})
|
||||
const [idSort, setIdSort] = useState(() => {
|
||||
return localStorage.getItem('channels-id-sort') === 'true'
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>(() => [
|
||||
{
|
||||
id:
|
||||
localStorage.getItem('channels-id-sort') === 'true' ? 'id' : 'priority',
|
||||
desc: true,
|
||||
},
|
||||
])
|
||||
const idSort = sorting[0]?.id === 'id'
|
||||
useEffect(() => {
|
||||
// Retain the existing ID-sort preference for returning users.
|
||||
localStorage.setItem('channels-id-sort', String(idSort))
|
||||
}, [idSort])
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
const [priorityActionContainer, setPriorityActionContainer] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const refreshChannels = useCallback(async () => {
|
||||
@@ -112,12 +127,14 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
setCurrentTag,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
sorting,
|
||||
setSorting,
|
||||
batchMode,
|
||||
setBatchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
priorityActionContainer,
|
||||
setPriorityActionContainer,
|
||||
upstream,
|
||||
}),
|
||||
[
|
||||
@@ -125,9 +142,10 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
currentRow,
|
||||
currentTag,
|
||||
enableTagMode,
|
||||
idSort,
|
||||
sorting,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
priorityActionContainer,
|
||||
upstream,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -23,9 +23,11 @@ import type {
|
||||
OnChangeFn,
|
||||
SortingState,
|
||||
Row,
|
||||
ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff, RefreshCw } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
useDataTableViewMode,
|
||||
} from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -44,7 +47,10 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { hasPermission } from '@/lib/admin-permissions'
|
||||
import { requireServerSuccess } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
@@ -52,6 +58,8 @@ import {
|
||||
CHANNEL_STATUS,
|
||||
CHANNEL_STATUS_OPTIONS,
|
||||
} from '../constants'
|
||||
import { useChannelPriorityDrag } from '../hooks/use-channel-priority-drag'
|
||||
import { useChannelPriorityOrder } from '../hooks/use-channel-priority-order'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
aggregateChannelsByTag,
|
||||
@@ -61,6 +69,13 @@ import {
|
||||
} from '../lib'
|
||||
import type { Channel, ChannelSortBy } from '../types'
|
||||
import { ChannelCard } from './channel-card'
|
||||
import { ChannelPriorityContext } from './channel-priority-context'
|
||||
import {
|
||||
ChannelPriorityHandle,
|
||||
ChannelPriorityModeToggle,
|
||||
ChannelPriorityStatus,
|
||||
} from './channel-priority-controls'
|
||||
import { ChannelPriorityDropHint } from './channel-priority-drop-hint'
|
||||
import { ChannelTypeLogo } from './channel-type-badge'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { useChannels } from './channels-provider'
|
||||
@@ -91,15 +106,16 @@ export function ChannelsTable() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
enableTagMode,
|
||||
idSort,
|
||||
sorting,
|
||||
setSorting,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
priorityActionContainer,
|
||||
} = useChannels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const idSort = sorting[0]?.id === 'id'
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
@@ -194,13 +210,10 @@ export function ChannelsTable() {
|
||||
}, [sorting])
|
||||
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
setSorting((previous) => {
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||
if (pagination.pageIndex > 0) {
|
||||
onPaginationChange({ ...pagination, pageIndex: 0 })
|
||||
}
|
||||
return next
|
||||
})
|
||||
setSorting(updater)
|
||||
if (pagination.pageIndex > 0) {
|
||||
onPaginationChange({ ...pagination, pageIndex: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch groups for filter
|
||||
@@ -220,7 +233,7 @@ export function ChannelsTable() {
|
||||
|
||||
// Fetch channels data
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: channelsQueryKeys.list({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
@@ -308,12 +321,89 @@ export function ChannelsTable() {
|
||||
const totalCount = data?.data?.total || 0
|
||||
const typeCounts = data?.data?.type_counts
|
||||
|
||||
// Columns configuration
|
||||
const columns = useChannelsColumns({ enableSelection: batchMode })
|
||||
const [viewMode, setViewMode] = useDataTableViewMode({
|
||||
storageKey: CHANNELS_VIEW_MODE_STORAGE_KEY,
|
||||
defaultMode: 'card',
|
||||
})
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const sortField = (sorting[0]?.id ??
|
||||
(idSort ? 'id' : 'priority')) as ChannelSortBy
|
||||
const sortDesc = sorting[0]?.desc ?? true
|
||||
const [reorderEnabled, setReorderEnabled] = useState(false)
|
||||
const canReorder =
|
||||
sortField === 'priority' &&
|
||||
!enableTagMode &&
|
||||
hasPermission(user, 'channel', 'write')
|
||||
const reorderActive = reorderEnabled && canReorder
|
||||
useEffect(() => {
|
||||
if (!canReorder) setReorderEnabled(false)
|
||||
}, [canReorder])
|
||||
const reorderScope = JSON.stringify([
|
||||
viewMode,
|
||||
reorderActive,
|
||||
sorting,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
channels.map((channel) => [channel.id, channel.priority]),
|
||||
])
|
||||
const priorityOrder = useChannelPriorityOrder({
|
||||
channels,
|
||||
desc: sortDesc,
|
||||
scope: reorderScope,
|
||||
enabled: reorderActive,
|
||||
})
|
||||
const drag = useChannelPriorityDrag({
|
||||
items: channels,
|
||||
desc: sortDesc,
|
||||
enabled: reorderActive && !priorityOrder.saving && !isFetching,
|
||||
scope: reorderScope,
|
||||
begin: priorityOrder.begin,
|
||||
select: priorityOrder.select,
|
||||
commit: priorityOrder.commit,
|
||||
cancel: priorityOrder.cancel,
|
||||
previewStore: priorityOrder.previewStore,
|
||||
})
|
||||
let dragReason: 'sort' | 'tag' | 'permission' | 'busy' | null = null
|
||||
if (sortField !== 'priority') dragReason = 'sort'
|
||||
else if (enableTagMode) dragReason = 'tag'
|
||||
else if (!hasPermission(user, 'channel', 'write')) dragReason = 'permission'
|
||||
else if (priorityOrder.saving || isFetching) dragReason = 'busy'
|
||||
|
||||
const dragControls = useMemo(
|
||||
() => ({ reason: dragReason, start: drag.start, keyDown: drag.keyDown }),
|
||||
[dragReason, drag.start, drag.keyDown]
|
||||
)
|
||||
|
||||
// Reuse the same handle cell in the table and channel cards.
|
||||
const baseColumns = useChannelsColumns({ enableSelection: batchMode })
|
||||
const columns = useMemo<ColumnDef<Channel>[]>(
|
||||
() =>
|
||||
reorderActive
|
||||
? [
|
||||
{
|
||||
id: 'reorder',
|
||||
header: '',
|
||||
size: 40,
|
||||
minSize: 40,
|
||||
maxSize: 40,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <ChannelPriorityHandle id={row.original.id} />,
|
||||
},
|
||||
...baseColumns,
|
||||
]
|
||||
: baseColumns,
|
||||
[baseColumns, reorderActive]
|
||||
)
|
||||
|
||||
// React Table instance
|
||||
const { table } = useDataTable({
|
||||
data: channels,
|
||||
data: priorityOrder.items,
|
||||
columns,
|
||||
totalCount,
|
||||
sorting,
|
||||
@@ -411,91 +501,150 @@ export function ChannelsTable() {
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Channels Found')}
|
||||
emptyDescription={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='channel-skeleton'
|
||||
enableCardView
|
||||
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
|
||||
renderCard={(row, { isSelected }) => (
|
||||
<ChannelCard row={row} isSelected={isSelected} />
|
||||
)}
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
collapsibleOnMobile: true,
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
searchDebounceMs: 500,
|
||||
onReset: () => {
|
||||
resetModelFilterInput()
|
||||
},
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={onModelFilterInputChange}
|
||||
onCompositionStart={onModelFilterCompositionStart}
|
||||
onCompositionEnd={onModelFilterCompositionEnd}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
preActions: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? <Eye /> : <EyeOff />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sensitiveVisible ? t('Hide') : t('Show')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}}
|
||||
getRowClassName={(row, { isMobile }) => {
|
||||
if (!isDisabledChannelRow(row.original)) {
|
||||
return undefined
|
||||
}
|
||||
if (isMobile) {
|
||||
return DISABLED_ROW_MOBILE
|
||||
}
|
||||
return DISABLED_ROW_DESKTOP
|
||||
}}
|
||||
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
|
||||
/>
|
||||
<ChannelPriorityContext.Provider value={dragControls}>
|
||||
{priorityActionContainer &&
|
||||
createPortal(
|
||||
<ChannelPriorityModeToggle
|
||||
active={reorderActive}
|
||||
viewMode={viewMode}
|
||||
reason={dragReason}
|
||||
onChange={() => setReorderEnabled((value) => !value)}
|
||||
/>,
|
||||
priorityActionContainer
|
||||
)}
|
||||
<ChannelPriorityDropHint
|
||||
container={drag.feedbackContainer}
|
||||
order={priorityOrder}
|
||||
channels={channels}
|
||||
/>
|
||||
<div
|
||||
ref={drag.root}
|
||||
role='region'
|
||||
aria-label={t('Channels')}
|
||||
{...drag.rootEvents}
|
||||
className='relative h-full min-h-0 data-[priority-dragging=true]:select-none data-[priority-dragging=true]:[&_[data-sortable-id]]:pointer-events-none'
|
||||
>
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Channels Found')}
|
||||
emptyDescription={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='channel-skeleton'
|
||||
enableCardView
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
renderCard={(row, { isSelected }) => (
|
||||
<ChannelCard
|
||||
row={row}
|
||||
isSelected={isSelected}
|
||||
reorderEnabled={reorderActive}
|
||||
/>
|
||||
)}
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
|
||||
applyHeaderSize
|
||||
afterTable={<ChannelPriorityStatus order={priorityOrder} />}
|
||||
toolbarProps={{
|
||||
collapsibleOnMobile: true,
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
searchDebounceMs: 500,
|
||||
onReset: () => {
|
||||
resetModelFilterInput()
|
||||
},
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={onModelFilterInputChange}
|
||||
onCompositionStart={onModelFilterCompositionStart}
|
||||
onCompositionEnd={onModelFilterCompositionEnd}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
preActions: (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? (
|
||||
<Eye aria-hidden />
|
||||
) : (
|
||||
<EyeOff aria-hidden />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sensitiveVisible ? t('Hide') : t('Show')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => void refetch()}
|
||||
aria-label={t('Refresh')}
|
||||
aria-busy={isFetching}
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
aria-hidden
|
||||
className={cn(isFetching && 'animate-spin')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Refresh')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
getRowClassName={(row, { isMobile }) => {
|
||||
if (!isDisabledChannelRow(row.original)) {
|
||||
return undefined
|
||||
}
|
||||
if (isMobile) {
|
||||
return DISABLED_ROW_MOBILE
|
||||
}
|
||||
return DISABLED_ROW_DESKTOP
|
||||
}}
|
||||
bulkActions={
|
||||
batchMode ? <DataTableBulkActions table={table} /> : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ChannelPriorityContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
isChannelEnabled,
|
||||
isMultiKeyChannel,
|
||||
} from '../lib'
|
||||
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
import { useChannels } from './channels-provider'
|
||||
@@ -300,22 +299,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
{/* Detect Upstream Updates (only for fetchable channel types) */}
|
||||
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const meta = parseUpstreamUpdateMeta(channel.settings)
|
||||
if (
|
||||
meta.pendingAddModels.length > 0 ||
|
||||
meta.pendingRemoveModels.length > 0
|
||||
) {
|
||||
upstream.openModal(
|
||||
channel,
|
||||
meta.pendingAddModels,
|
||||
meta.pendingRemoveModels,
|
||||
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
|
||||
)
|
||||
} else {
|
||||
upstream.detectChannelUpdates(channel)
|
||||
}
|
||||
}}
|
||||
onClick={() => upstream.detectChannelUpdates(channel)}
|
||||
>
|
||||
{t('Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
@@ -50,6 +49,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
@@ -59,7 +59,14 @@ import {
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
@@ -673,7 +680,7 @@ export function AdvancedCustomEditorDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Advanced Custom Routes')}
|
||||
description={t('Advanced Custom')}
|
||||
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
|
||||
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] flex-col gap-0 p-0 sm:max-w-5xl'
|
||||
headerClassName='border-b px-6 py-4'
|
||||
footerClassName='border-t px-6 py-4'
|
||||
contentHeight='70vh'
|
||||
@@ -736,15 +743,15 @@ export function AdvancedCustomEditorDialog({
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Combobox
|
||||
options={availableIncomingPathOptions}
|
||||
value=''
|
||||
onValueChange={(incomingPath) => {
|
||||
options={availableIncomingPathOptions}
|
||||
value=''
|
||||
onValueChange={(incomingPath) => {
|
||||
if (typeof incomingPath === 'string') addRoute(incomingPath)
|
||||
}}
|
||||
disabled={availableIncomingPathOptions.length === 0}
|
||||
className='w-full'
|
||||
placeholder={t('Add route')}
|
||||
/>
|
||||
disabled={availableIncomingPathOptions.length === 0}
|
||||
className='w-full'
|
||||
placeholder={t('Add route')}
|
||||
/>
|
||||
<Select
|
||||
value={null}
|
||||
onValueChange={(value) => {
|
||||
@@ -1187,16 +1194,19 @@ function RouteGroupEditor({
|
||||
) : null}
|
||||
</div>
|
||||
<Combobox
|
||||
options={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ({
|
||||
...option,
|
||||
description: option.value,
|
||||
disabled: (option.value !== incomingPath && usedIncomingPaths.has(option.value)) ||
|
||||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH && group.routeRows.length > 1),
|
||||
}))}
|
||||
value={incomingPath}
|
||||
onValueChange={onIncomingPathChange}
|
||||
className='h-9 max-w-full lg:max-w-[420px]'
|
||||
/>
|
||||
options={ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ({
|
||||
...option,
|
||||
description: option.value,
|
||||
disabled:
|
||||
(option.value !== incomingPath &&
|
||||
usedIncomingPaths.has(option.value)) ||
|
||||
(option.value === ADVANCED_CUSTOM_MODEL_LIST_PATH &&
|
||||
group.routeRows.length > 1),
|
||||
}))}
|
||||
value={incomingPath}
|
||||
onValueChange={onIncomingPathChange}
|
||||
className='h-9 max-w-full lg:max-w-[420px]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -969,7 +969,7 @@ function ChannelTestDialogContent({
|
||||
<span className='min-w-0 truncate'>{currentRow.name}</span>
|
||||
</span>
|
||||
}
|
||||
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-4xl'
|
||||
contentClassName='max-h-[min(90dvh,var(--dialog-available-height))] overflow-y-auto sm:max-w-4xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
|
||||
@@ -76,7 +76,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatDateTimeStr, formatTimestampToDate } from '@/lib/format'
|
||||
import { createServerError } from '@/lib/server-error-message'
|
||||
import { createServerError, getServerMessage } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
@@ -966,7 +966,7 @@ export function CodexUsageDialog({
|
||||
|
||||
const errorMessage =
|
||||
response?.success === false
|
||||
? response?.message?.trim() || t('Failed to fetch usage')
|
||||
? getServerMessage(response)?.trim() || t('Failed to fetch usage')
|
||||
: ''
|
||||
|
||||
const loadResetCredits = useCallback(
|
||||
|
||||
@@ -225,7 +225,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
description={t(
|
||||
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
|
||||
)}
|
||||
contentClassName='max-h-[90vh] max-w-2xl'
|
||||
contentClassName='max-h-[min(90dvh,var(--dialog-available-height))] max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { getServerMessage } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
@@ -191,7 +192,7 @@ export function MultiKeyManageDialog({
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message || t('Operation successful'))
|
||||
toast.success(getServerMessage(response) || t('Operation successful'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
|
||||
// Reload data - reset to page 1 for bulk actions
|
||||
@@ -261,7 +262,7 @@ export function MultiKeyManageDialog({
|
||||
description={t(
|
||||
'Manage multi-key status and configuration for this channel'
|
||||
)}
|
||||
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
|
||||
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] max-w-5xl flex-col'
|
||||
titleClassName='flex items-center gap-2'
|
||||
contentHeight='min(72vh, 720px)'
|
||||
bodyClassName='space-y-4'
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getFreshAuthHeaders } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { getServerMessage } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
deleteOllamaModel,
|
||||
@@ -136,8 +137,8 @@ export function OllamaModelsDialog({
|
||||
})
|
||||
if (payloadLive?.success) {
|
||||
normalized = normalizeOllamaModels(payloadLive.data)
|
||||
} else if (payloadLive?.message) {
|
||||
lastErr = String(payloadLive.message)
|
||||
} else {
|
||||
lastErr = getServerMessage(payloadLive) || ''
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
lastErr = err instanceof Error ? err.message : ''
|
||||
@@ -151,7 +152,7 @@ export function OllamaModelsDialog({
|
||||
normalized = normalizeOllamaModels(payload.data)
|
||||
lastErr = ''
|
||||
} else {
|
||||
lastErr = String(payload?.message || '')
|
||||
lastErr = getServerMessage(payload) || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ type ParamOverrideOperation = {
|
||||
keep_origin: boolean
|
||||
logic: string
|
||||
conditions: ParamOverrideCondition[]
|
||||
prune_draft: PruneObjectsDraft | null
|
||||
}
|
||||
|
||||
export type ParamOverrideEditorDialogProps = {
|
||||
@@ -140,6 +141,7 @@ const CONDITION_MODE_OPTIONS = [
|
||||
{ label: 'Prefix', value: 'prefix' },
|
||||
{ label: 'Suffix', value: 'suffix' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
{ label: 'Greater Than', value: 'gt' },
|
||||
{ label: 'Greater Than or Equal', value: 'gte' },
|
||||
{ label: 'Less Than', value: 'lt' },
|
||||
@@ -473,6 +475,14 @@ const parseLooseValue = (valueText: string): unknown => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseConditionValue = (
|
||||
condition: Pick<ParamOverrideCondition, 'mode' | 'value_text'>
|
||||
): unknown => {
|
||||
// Regex patterns are literal strings, even when they look like JSON values.
|
||||
if (condition.mode === 'regex') return condition.value_text
|
||||
return parseLooseValue(condition.value_text)
|
||||
}
|
||||
|
||||
const verifyJSON = (text: string): boolean => {
|
||||
try {
|
||||
JSON.parse(text)
|
||||
@@ -509,6 +519,10 @@ const normalizeOperation = (
|
||||
? (operation.mode as string)
|
||||
: 'set',
|
||||
value_text: toValueText(operation.value),
|
||||
prune_draft:
|
||||
operation.mode === 'prune_objects'
|
||||
? parsePruneObjectsDraft(toValueText(operation.value))
|
||||
: null,
|
||||
keep_origin: operation.keep_origin === true,
|
||||
from: typeof operation.from === 'string' ? operation.from : '',
|
||||
to: typeof operation.to === 'string' ? operation.to : '',
|
||||
@@ -762,14 +776,7 @@ const buildReturnErrorValueText = (
|
||||
|
||||
// prune_objects helpers
|
||||
|
||||
type PruneRule = {
|
||||
id: string
|
||||
path: string
|
||||
mode: string
|
||||
value_text: string
|
||||
invert: boolean
|
||||
pass_missing_key: boolean
|
||||
}
|
||||
type PruneRule = ParamOverrideCondition
|
||||
|
||||
type PruneObjectsDraft = {
|
||||
simpleMode: boolean
|
||||
@@ -779,17 +786,6 @@ type PruneObjectsDraft = {
|
||||
rules: PruneRule[]
|
||||
}
|
||||
|
||||
const normalizePruneRule = (rule: Record<string, unknown> = {}): PruneRule => ({
|
||||
id: nextLocalId(),
|
||||
path: typeof rule.path === 'string' ? rule.path : '',
|
||||
mode: CONDITION_MODE_VALUES.has(rule.mode as string)
|
||||
? (rule.mode as string)
|
||||
: 'full',
|
||||
value_text: toValueText(rule.value),
|
||||
invert: rule.invert === true,
|
||||
pass_missing_key: rule.pass_missing_key === true,
|
||||
})
|
||||
|
||||
const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
|
||||
const defaults: PruneObjectsDraft = {
|
||||
simpleMode: true,
|
||||
@@ -815,13 +811,13 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
|
||||
for (const [path, value] of Object.entries(
|
||||
parsed.where as Record<string, unknown>
|
||||
)) {
|
||||
rules.push(normalizePruneRule({ path, mode: 'full', value }))
|
||||
rules.push(normalizeCondition({ path, mode: 'full', value }))
|
||||
}
|
||||
}
|
||||
if (Array.isArray(parsed.conditions)) {
|
||||
for (const item of parsed.conditions) {
|
||||
if (item && typeof item === 'object') {
|
||||
rules.push(normalizePruneRule(item))
|
||||
rules.push(normalizeCondition(item))
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
@@ -832,7 +828,7 @@ const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
|
||||
for (const [path, value] of Object.entries(
|
||||
parsed.conditions as Record<string, unknown>
|
||||
)) {
|
||||
rules.push(normalizePruneRule({ path, mode: 'full', value }))
|
||||
rules.push(normalizeCondition({ path, mode: 'full', value }))
|
||||
}
|
||||
}
|
||||
const typeText =
|
||||
@@ -874,8 +870,9 @@ const buildPruneObjectsValueText = (draft: PruneObjectsDraft): string => {
|
||||
path: String(rule.path || '').trim(),
|
||||
mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full',
|
||||
}
|
||||
const valueRaw = String(rule.value_text || '').trim()
|
||||
if (valueRaw !== '') conditionPayload.value = parseLooseValue(valueRaw)
|
||||
if (rule.mode === 'regex' || rule.value_text.trim() !== '') {
|
||||
conditionPayload.value = parseConditionValue(rule)
|
||||
}
|
||||
if (rule.invert) conditionPayload.invert = true
|
||||
if (rule.pass_missing_key) conditionPayload.pass_missing_key = true
|
||||
return conditionPayload
|
||||
@@ -922,7 +919,7 @@ const buildConditionPayload = (
|
||||
const payload: Record<string, unknown> = {
|
||||
path,
|
||||
mode: condition.mode || 'full',
|
||||
value: parseLooseValue(condition.value_text),
|
||||
value: parseConditionValue(condition),
|
||||
}
|
||||
if (condition.invert) payload.invert = true
|
||||
if (condition.pass_missing_key) payload.pass_missing_key = true
|
||||
@@ -982,7 +979,18 @@ const validateOperations = (
|
||||
|
||||
if (mode === 'prune_objects') {
|
||||
const raw = op.value_text.trim()
|
||||
if (!raw) {
|
||||
const draft = op.prune_draft
|
||||
const hasDraftCondition =
|
||||
draft &&
|
||||
(draft.typeText.trim() ||
|
||||
(!draft.simpleMode && draft.rules.some((rule) => rule.path.trim())))
|
||||
const parsed = parseLooseValue(raw)
|
||||
// Imported object rules can explicitly match an empty type string.
|
||||
const hasExplicitType =
|
||||
parsed !== null &&
|
||||
typeof parsed === 'object' &&
|
||||
Object.hasOwn(parsed, 'type')
|
||||
if (!raw || (draft && !hasDraftCondition && !hasExplicitType)) {
|
||||
return t('Rule {{line}} prune_objects is missing conditions', { line })
|
||||
}
|
||||
}
|
||||
@@ -1248,12 +1256,7 @@ export function ParamOverrideEditorDialog(
|
||||
return parseReturnErrorDraft(selectedOperation.value_text)
|
||||
}, [selectedOperation])
|
||||
|
||||
const pruneObjectsDraft = useMemo(() => {
|
||||
if (!selectedOperation || selectedOperation.mode !== 'prune_objects') {
|
||||
return null
|
||||
}
|
||||
return parsePruneObjectsDraft(selectedOperation.value_text)
|
||||
}, [selectedOperation])
|
||||
const pruneObjectsDraft = selectedOperation?.prune_draft ?? null
|
||||
|
||||
const topOperationModes = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
@@ -1273,7 +1276,17 @@ export function ParamOverrideEditorDialog(
|
||||
const updateOperation = useCallback(
|
||||
(operationId: string, patch: Partial<ParamOverrideOperation>) => {
|
||||
setOperations((prev) =>
|
||||
prev.map((o) => (o.id === operationId ? { ...o, ...patch } : o))
|
||||
prev.map((op) => {
|
||||
if (op.id !== operationId) return op
|
||||
const next = { ...op, ...patch }
|
||||
if (next.mode !== op.mode || patch.value_text !== undefined) {
|
||||
next.prune_draft =
|
||||
next.mode === 'prune_objects'
|
||||
? parsePruneObjectsDraft(next.value_text)
|
||||
: null
|
||||
}
|
||||
return next
|
||||
})
|
||||
)
|
||||
},
|
||||
[]
|
||||
@@ -1303,11 +1316,20 @@ export function ParamOverrideEditorDialog(
|
||||
conditions: source.conditions.map((c) => ({
|
||||
path: c.path,
|
||||
mode: c.mode,
|
||||
value: parseLooseValue(c.value_text),
|
||||
value: parseConditionValue(c),
|
||||
invert: c.invert,
|
||||
pass_missing_key: c.pass_missing_key,
|
||||
})),
|
||||
})
|
||||
if (source.prune_draft) {
|
||||
cloned.prune_draft = {
|
||||
...source.prune_draft,
|
||||
rules: source.prune_draft.rules.map((rule) => ({
|
||||
...rule,
|
||||
id: nextLocalId(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
insertedId = cloned.id
|
||||
const next = [...prev]
|
||||
next.splice(idx + 1, 0, cloned)
|
||||
@@ -1403,13 +1425,17 @@ export function ParamOverrideEditorDialog(
|
||||
setOperations((prev) =>
|
||||
prev.map((op) => {
|
||||
if (op.id !== operationId) return op
|
||||
const draft = parsePruneObjectsDraft(op.value_text)
|
||||
const draft = op.prune_draft
|
||||
if (!draft) return op
|
||||
const nextDraft =
|
||||
typeof updater === 'function'
|
||||
? updater(draft)
|
||||
: { ...draft, ...updater }
|
||||
return {
|
||||
...op,
|
||||
// Keep unfinished rows and their IDs in local state. The serialized
|
||||
// value is only the valid payload, never the source of the next edit.
|
||||
prune_draft: nextDraft,
|
||||
value_text: buildPruneObjectsValueText(nextDraft),
|
||||
}
|
||||
})
|
||||
@@ -1423,7 +1449,7 @@ export function ParamOverrideEditorDialog(
|
||||
updatePruneObjectsDraft(operationId, (draft) => ({
|
||||
...draft,
|
||||
simpleMode: false,
|
||||
rules: [...draft.rules, normalizePruneRule({})],
|
||||
rules: [...draft.rules, createDefaultCondition()],
|
||||
}))
|
||||
},
|
||||
[updatePruneObjectsDraft]
|
||||
@@ -1747,7 +1773,7 @@ export function ParamOverrideEditorDialog(
|
||||
description={t(
|
||||
'Create request parameter override rules with a visual editor or raw JSON.'
|
||||
)}
|
||||
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
|
||||
contentClassName='flex max-h-[min(90dvh,var(--dialog-available-height))] flex-col gap-0 p-0 sm:max-w-5xl'
|
||||
headerClassName='border-b px-6 py-4'
|
||||
footerClassName='border-t px-6 py-4'
|
||||
contentHeight='min(72vh, 720px)'
|
||||
@@ -2563,6 +2589,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
{t('Match Mode')}
|
||||
</label>
|
||||
<Combobox
|
||||
aria-label={t('Match Mode')}
|
||||
options={CONDITION_MODE_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.label),
|
||||
@@ -2584,6 +2611,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
{t('Match Value')}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t('Match Value')}
|
||||
value={condition.value_text}
|
||||
onChange={(e) =>
|
||||
conditionEditorProps.updateCondition(
|
||||
@@ -2592,7 +2620,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
{ value_text: e.target.value }
|
||||
)
|
||||
}
|
||||
placeholder='gpt'
|
||||
placeholder={condition.mode === 'regex' ? '^gpt-' : 'gpt'}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
@@ -3069,6 +3097,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
{t('Match Mode')}
|
||||
</label>
|
||||
<Combobox
|
||||
aria-label={t('Match Mode')}
|
||||
options={CONDITION_MODE_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.label),
|
||||
@@ -3090,6 +3119,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
{t('Match Value (optional)')}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t('Match Value (optional)')}
|
||||
value={rule.value_text}
|
||||
onChange={(e) =>
|
||||
pruneObjectsEditorProps.updateRule(
|
||||
|
||||
@@ -16,277 +16,295 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ErrorState } from '@/components/error-state'
|
||||
import { LoadingState } from '@/components/loading-state'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
|
||||
interface UpstreamUpdateDialogProps {
|
||||
open: boolean
|
||||
addModels: string[]
|
||||
removeModels: string[]
|
||||
preferredTab: 'add' | 'remove'
|
||||
confirmLoading: boolean
|
||||
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
|
||||
onCancel: () => void
|
||||
import type { ChannelUpstreamUpdateState } from '../../hooks/use-channel-upstream-updates'
|
||||
import { UpstreamModelSelection } from '../upstream-model-selection'
|
||||
|
||||
type UpstreamUpdateDialogProps = {
|
||||
upstream: ChannelUpstreamUpdateState
|
||||
}
|
||||
|
||||
function ModelChangeList(props: { title: string; models: string[] }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
return (
|
||||
<section aria-label={props.title} className='min-w-0 space-y-2'>
|
||||
<h3 className='text-sm font-medium'>
|
||||
{props.title} ({formatNumber(props.models.length, locale)})
|
||||
</h3>
|
||||
{props.models.length ? (
|
||||
<ul className='max-h-40 space-y-1 overflow-y-auto rounded-md border p-3 font-mono text-xs break-all'>
|
||||
{props.models.map((model) => (
|
||||
<li key={model}>{model}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>{t('None')}</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
if (!props.upstream.showModal || !props.upstream.channel) return null
|
||||
// Each preview gets fresh selections, including when detection finishes or
|
||||
// another channel is opened. Keeping a hidden dialog mounted retained stale state.
|
||||
return (
|
||||
<UpstreamUpdateSession
|
||||
key={`${props.upstream.previewVersion}-${props.upstream.result ? 'result' : 'preview'}`}
|
||||
upstream={props.upstream}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function UpstreamUpdateSession(props: UpstreamUpdateDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState(props.preferredTab)
|
||||
const [searchAdd, setSearchAdd] = useState('')
|
||||
const [searchRemove, setSearchRemove] = useState('')
|
||||
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
|
||||
() => new Set(props.addModels)
|
||||
)
|
||||
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
|
||||
() => new Set(props.removeModels)
|
||||
)
|
||||
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
|
||||
|
||||
const filteredAdd = useMemo(
|
||||
() =>
|
||||
props.addModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchAdd.toLowerCase())
|
||||
),
|
||||
[props.addModels, searchAdd]
|
||||
)
|
||||
|
||||
const filteredRemove = useMemo(
|
||||
() =>
|
||||
props.removeModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchRemove.toLowerCase())
|
||||
),
|
||||
[props.removeModels, searchRemove]
|
||||
)
|
||||
|
||||
const toggleModel = (
|
||||
model: string,
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(model)) next.delete(model)
|
||||
else next.add(model)
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const toggleAllVisible = (
|
||||
models: string[],
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const allSelected = models.every((m) => set.has(m))
|
||||
const next = new Set(set)
|
||||
if (allSelected) {
|
||||
models.forEach((m) => next.delete(m))
|
||||
} else {
|
||||
models.forEach((m) => next.add(m))
|
||||
}
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const hasAdd = props.addModels.length > 0
|
||||
const hasRemove = props.removeModels.length > 0
|
||||
const selectedAddArr = Array.from(selectedAdd)
|
||||
const selectedRemoveArr = Array.from(selectedRemove)
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
|
||||
setPartialConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
props.onConfirm({
|
||||
addModels: selectedAddArr,
|
||||
removeModels: selectedRemoveArr,
|
||||
})
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState(props.upstream.preferredTab)
|
||||
const [selectedAdd, setSelectedAdd] = useState(props.upstream.addModels)
|
||||
const [selectedRemove, setSelectedRemove] = useState<string[]>([])
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const result = props.upstream.result
|
||||
const hasChanges =
|
||||
props.upstream.addModels.length > 0 ||
|
||||
props.upstream.removeModels.length > 0
|
||||
const isPreview =
|
||||
!props.upstream.detectLoading && !props.upstream.detectError && !result
|
||||
const channelLabel = `#${props.upstream.channel?.id} ${props.upstream.channel?.name ?? ''}`
|
||||
const summary = t('Add {{added}} models and remove {{removed}} models', {
|
||||
added: selectedAdd.length,
|
||||
removed: selectedRemove.length,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(v) => !v && props.onCancel()}
|
||||
title={t('Upstream Model Updates')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
contentHeight='auto'
|
||||
open
|
||||
onOpenChange={(open) => !open && props.upstream.closeModal()}
|
||||
showCloseButton={!props.upstream.applyLoading}
|
||||
title={
|
||||
result ? t('Update results') : t('Preview upstream model changes')
|
||||
}
|
||||
description={<span className='break-all'>{channelLabel}</span>}
|
||||
bodyClassName='space-y-4'
|
||||
titleClassName='pr-6 leading-snug'
|
||||
footerClassName='sm:flex-wrap'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
props.confirmLoading ||
|
||||
(props.addModels.length === 0 &&
|
||||
props.removeModels.length === 0)
|
||||
}
|
||||
variant='outline'
|
||||
onClick={props.upstream.closeModal}
|
||||
disabled={props.upstream.applyLoading}
|
||||
>
|
||||
{t('Confirm')}
|
||||
{isPreview && hasChanges ? t('Cancel') : t('Close')}
|
||||
</Button>
|
||||
{isPreview && (
|
||||
<>
|
||||
<Button
|
||||
className='h-auto min-h-8 max-w-full whitespace-normal'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
props.upstream.detectChannelUpdates(props.upstream.channel)
|
||||
}
|
||||
disabled={props.upstream.applyLoading}
|
||||
>
|
||||
{t('Refresh preview')}
|
||||
</Button>
|
||||
<Button
|
||||
className='h-auto min-h-8 max-w-full whitespace-normal'
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={
|
||||
props.upstream.applyLoading ||
|
||||
!!props.upstream.applyError ||
|
||||
(!selectedAdd.length && !selectedRemove.length)
|
||||
}
|
||||
>
|
||||
{t('Review selected changes')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Select models to process. Unselected "add" models will be ignored.'
|
||||
)}
|
||||
</p>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='add' className='gap-1'>
|
||||
{t('Add Models')}
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedAdd.size}/{props.addModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='remove' className='gap-1'>
|
||||
{t('Remove Models')}
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedRemove.size}/{props.removeModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='add' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchAdd}
|
||||
onChange={(e) => setSearchAdd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredAdd.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredAdd.every((m) => selectedAdd.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
{props.upstream.detectLoading && (
|
||||
<LoadingState
|
||||
message={t(
|
||||
'Checking upstream models. Channel models will only change after confirmation.'
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredAdd.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredAdd.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedAdd.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(model, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.addModels.length === 0
|
||||
? t('No models to add')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='remove' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchRemove}
|
||||
onChange={(e) => setSearchRemove(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredRemove.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredRemove.every((m) => selectedRemove.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(
|
||||
filteredRemove,
|
||||
selectedRemove,
|
||||
setSelectedRemove
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredRemove.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredRemove.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRemove.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(model, selectedRemove, setSelectedRemove)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.removeModels.length === 0
|
||||
? t('No models to remove')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={partialConfirmOpen}
|
||||
onOpenChange={setPartialConfirmOpen}
|
||||
title={t('Partial Submission')}
|
||||
desc={t(
|
||||
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
|
||||
/>
|
||||
)}
|
||||
handleConfirm={() => {
|
||||
setPartialConfirmOpen(false)
|
||||
props.onConfirm({
|
||||
addModels: Array.from(selectedAdd),
|
||||
removeModels: Array.from(selectedRemove),
|
||||
{props.upstream.detectError && (
|
||||
<ErrorState
|
||||
title={t('Detection failed')}
|
||||
description={props.upstream.detectError}
|
||||
onRetry={() =>
|
||||
props.upstream.detectChannelUpdates(props.upstream.channel)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{result && (
|
||||
<>
|
||||
<Alert>
|
||||
<AlertTitle>{t('Update completed')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'The server returned the following result. Unselected models remain pending.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ModelChangeList
|
||||
title={t('Added models')}
|
||||
models={result.addedModels}
|
||||
/>
|
||||
<ModelChangeList
|
||||
title={t('Removed models')}
|
||||
models={result.removedModels}
|
||||
/>
|
||||
<ModelChangeList
|
||||
title={t('Still pending addition')}
|
||||
models={result.remainingModels}
|
||||
/>
|
||||
<ModelChangeList
|
||||
title={t('Still pending removal')}
|
||||
models={result.remainingRemoveModels}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isPreview && (
|
||||
<>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Review the model changes before applying. Unselected models stay unchanged and will not be ignored.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{props.upstream.applyError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>
|
||||
{t('Unable to confirm the update result')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{props.upstream.applyError}</p>
|
||||
<p>
|
||||
{t(
|
||||
'Refresh the preview before trying again; some changes may already have been saved.'
|
||||
)}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<p className='text-sm font-medium' aria-live='polite'>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
{hasChanges ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as 'add' | 'remove')
|
||||
}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'>
|
||||
<TabsTrigger
|
||||
value='add'
|
||||
className='h-auto min-h-7 whitespace-normal'
|
||||
>
|
||||
{t('Add Models')} ({props.upstream.addModels.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='remove'
|
||||
className='h-auto min-h-7 whitespace-normal'
|
||||
>
|
||||
{t('Remove Models')} ({props.upstream.removeModels.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='add'>
|
||||
<UpstreamModelSelection
|
||||
models={props.upstream.addModels}
|
||||
selected={selectedAdd}
|
||||
onChange={(models) =>
|
||||
!props.upstream.applyLoading && setSelectedAdd(models)
|
||||
}
|
||||
existingModels={[]}
|
||||
summaryText={t('Add Models')}
|
||||
showChanges={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='remove' className='space-y-3'>
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Removed models will no longer be available through this channel. Select removals explicitly.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<UpstreamModelSelection
|
||||
models={props.upstream.removeModels}
|
||||
selected={selectedRemove}
|
||||
onChange={(models) =>
|
||||
!props.upstream.applyLoading && setSelectedRemove(models)
|
||||
}
|
||||
existingModels={[]}
|
||||
summaryText={t('Remove Models')}
|
||||
showChanges={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t(
|
||||
'No processable upstream model updates for this channel'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen && !result}
|
||||
onOpenChange={(open) =>
|
||||
!props.upstream.applyLoading && setConfirmOpen(open)
|
||||
}
|
||||
title={t('Confirm model changes')}
|
||||
desc={
|
||||
<>
|
||||
<span className='break-all'>{channelLabel}</span>
|
||||
<p>{summary}</p>
|
||||
</>
|
||||
}
|
||||
confirmText={
|
||||
props.upstream.applyLoading
|
||||
? t('Applying...')
|
||||
: t('Apply selected changes')
|
||||
}
|
||||
destructive={selectedRemove.length > 0}
|
||||
isLoading={props.upstream.applyLoading}
|
||||
className='max-h-(--dialog-available-height) overflow-y-auto [&_button]:h-auto [&_button]:min-h-8 [&_button]:whitespace-normal'
|
||||
handleConfirm={async () => {
|
||||
await props.upstream.applyUpdates({
|
||||
addModels: selectedAdd,
|
||||
removeModels: selectedRemove,
|
||||
})
|
||||
setConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<ModelChangeList title={t('Add Models')} models={selectedAdd} />
|
||||
<ModelChangeList title={t('Remove Models')} models={selectedRemove} />
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -105,6 +105,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
|
||||
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
|
||||
import {
|
||||
@@ -139,6 +140,7 @@ import {
|
||||
ADD_MODE_OPTIONS,
|
||||
CLAUDE_FIELD_PASSTHROUGH_TYPES,
|
||||
CHANNEL_STATUS_LABELS,
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
CHANNEL_TYPE_OLLAMA,
|
||||
CHANNEL_TYPE_OPTIONS,
|
||||
CHANNEL_TYPE_TASK_PLUGIN,
|
||||
@@ -175,11 +177,7 @@ import {
|
||||
parseModelsString,
|
||||
formatModelsArray,
|
||||
mergeModelMappingPairs,
|
||||
deriveModelMappingPairs,
|
||||
detectModelNamingPatterns,
|
||||
findVerifiedAlias,
|
||||
type ModelMappingPair,
|
||||
type ModelNamingSuggestion,
|
||||
extractRedirectModels,
|
||||
extractMappingSourceModels,
|
||||
hasModelConfigChanged,
|
||||
@@ -196,6 +194,7 @@ import {
|
||||
import {
|
||||
getChannelPluginExtensions,
|
||||
supportsChannelPluginExtensions,
|
||||
supportsNewAPIUpstream,
|
||||
} from '../../lib/channel-plugin-extensions'
|
||||
import { getChannelTypeConfig } from '../../lib/channel-type-config'
|
||||
import {
|
||||
@@ -294,13 +293,16 @@ const SENSITIVE_FORM_FIELDS = [
|
||||
'advanced_custom',
|
||||
'is_enterprise_account',
|
||||
'vertex_key_type',
|
||||
'vertex_project_id',
|
||||
'aws_key_type',
|
||||
'aws_api_type',
|
||||
'azure_responses_version',
|
||||
'force_format',
|
||||
'thinking_to_content',
|
||||
'proxy',
|
||||
'http_protocol',
|
||||
'http2_connection_shards',
|
||||
'tls_insecure_skip_verify',
|
||||
'pass_through_body_enabled',
|
||||
'responses_websocket_enabled',
|
||||
'system_prompt',
|
||||
@@ -481,7 +483,6 @@ export function ChannelMutateDrawer({
|
||||
const mappingDraftTokenRef = useRef(0)
|
||||
const [batchMapping, setBatchMapping] = useState<{
|
||||
source: ModelMappingBatchSource
|
||||
selected: string[]
|
||||
} | null>(null)
|
||||
const [redirectPanelOpen, setRedirectPanelOpen] = useState(false)
|
||||
const [redirectSyncModels, setRedirectSyncModels] = useState(true)
|
||||
@@ -594,6 +595,7 @@ export function ChannelMutateDrawer({
|
||||
const currentStatus = formValues.status
|
||||
const currentBaseUrl = formValues.base_url
|
||||
const currentTaskPluginKey = formValues.task_plugin_key
|
||||
const currentTaskExtendPluginKeys = formValues.task_extend_plugin_keys
|
||||
const currentKey = formValues.key
|
||||
const currentModels = formValues.models
|
||||
const currentModelMapping = formValues.model_mapping
|
||||
@@ -771,10 +773,15 @@ export function ChannelMutateDrawer({
|
||||
const canHavePluginExtensions = supportsChannelPluginExtensions(currentType)
|
||||
const pluginExtensions = useMemo(() => {
|
||||
if (!canBindTaskPlugin || !taskPluginOptionsQuery.isSuccess) return []
|
||||
return getChannelPluginExtensions(currentType, taskPluginOptionsQuery.data)
|
||||
return getChannelPluginExtensions(
|
||||
currentType,
|
||||
taskPluginOptionsQuery.data,
|
||||
currentTaskExtendPluginKeys
|
||||
)
|
||||
}, [
|
||||
canBindTaskPlugin,
|
||||
currentType,
|
||||
currentTaskExtendPluginKeys,
|
||||
taskPluginOptionsQuery.isSuccess,
|
||||
taskPluginOptionsQuery.data,
|
||||
])
|
||||
@@ -1238,10 +1245,6 @@ export function ChannelMutateDrawer({
|
||||
upstreamModelList.length > 0 ? 'upstream' : 'channel'
|
||||
const canBatchMap =
|
||||
currentModelsArray.length > 0 || upstreamModelList.length > 0
|
||||
const namingSuggestions = useMemo(
|
||||
() => detectModelNamingPatterns(upstreamModelList, allModelsList),
|
||||
[upstreamModelList, allModelsList]
|
||||
)
|
||||
const fetchDiscoveredModels = discovery.fetch
|
||||
const handleFetchModels = useCallback(async () => {
|
||||
const type = form.getValues('type')
|
||||
@@ -1329,6 +1332,46 @@ export function ChannelMutateDrawer({
|
||||
[form]
|
||||
)
|
||||
|
||||
const taskPluginExtensionOptions = useMemo(
|
||||
() =>
|
||||
(taskPluginOptionsQuery.data ?? [])
|
||||
.filter(supportsNewAPIUpstream)
|
||||
.map((plugin) => ({
|
||||
value: plugin.key,
|
||||
label: plugin.name,
|
||||
hint: plugin.key,
|
||||
icon: <PluginIcon plugin={plugin} size={16} />,
|
||||
})),
|
||||
[taskPluginOptionsQuery.data]
|
||||
)
|
||||
|
||||
// Binding an upstream plugin publishes its models like the type-61 prefill;
|
||||
// unbinding removes the models that no remaining bound plugin declares.
|
||||
const handleTaskExtendPluginKeysChange = useCallback(
|
||||
(keys: string[]) => {
|
||||
const plugins = taskPluginOptionsQuery.data ?? []
|
||||
const declaredBy = (bound: readonly string[]) =>
|
||||
new Set(
|
||||
plugins
|
||||
.filter((plugin) => bound.includes(plugin.key))
|
||||
.flatMap((plugin) => plugin.models)
|
||||
)
|
||||
const previous = form.getValues('task_extend_plugin_keys') ?? []
|
||||
const added = declaredBy(keys.filter((key) => !previous.includes(key)))
|
||||
const kept = declaredBy(keys)
|
||||
const dropped = declaredBy(previous.filter((key) => !keys.includes(key)))
|
||||
const models = parseModelsString(form.getValues('models') || '').filter(
|
||||
(model) => kept.has(model) || !dropped.has(model)
|
||||
)
|
||||
for (const model of added) {
|
||||
if (!models.includes(model)) models.push(model)
|
||||
}
|
||||
form.setValue('task_extend_plugin_keys', keys, { shouldDirty: true })
|
||||
form.setValue('models', models.join(','), { shouldDirty: true })
|
||||
},
|
||||
[form, taskPluginOptionsQuery.data]
|
||||
)
|
||||
|
||||
const raiseMappingDraft = useCallback(
|
||||
(from: string, to: string, focus?: 'from' | 'to') => {
|
||||
mappingDraftTokenRef.current += 1
|
||||
@@ -1447,9 +1490,7 @@ export function ChannelMutateDrawer({
|
||||
setRedirectPanelOpen(false)
|
||||
}, [])
|
||||
|
||||
// Redirect one fetched upstream model. On wide screens the floating panel
|
||||
// stays beside the list; a name the platform already knows is applied at
|
||||
// once, anything else becomes a draft row awaiting the request name.
|
||||
// Open a draft without guessing or publishing a request name.
|
||||
const handleFetchedRedirect = useCallback(
|
||||
(model: string) => {
|
||||
if (window.innerWidth < REDIRECT_PANEL_MIN_VIEWPORT) {
|
||||
@@ -1457,35 +1498,9 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
openRedirectPanel()
|
||||
const alias = findVerifiedAlias(model, namingSuggestions, allModelsList)
|
||||
if (alias) {
|
||||
applyMappingPairs([alias], redirectSyncModels)
|
||||
raiseMappingDraft(alias.from, alias.to, 'from')
|
||||
return
|
||||
}
|
||||
raiseMappingDraft('', model, 'from')
|
||||
},
|
||||
[
|
||||
requestMappingDraft,
|
||||
openRedirectPanel,
|
||||
namingSuggestions,
|
||||
allModelsList,
|
||||
applyMappingPairs,
|
||||
redirectSyncModels,
|
||||
raiseMappingDraft,
|
||||
]
|
||||
)
|
||||
|
||||
const handleApplySuggestion = useCallback(
|
||||
(suggestion: ModelNamingSuggestion) => {
|
||||
const derivation = deriveModelMappingPairs(
|
||||
suggestion.models,
|
||||
'upstream',
|
||||
suggestion.rule
|
||||
)
|
||||
applyMappingPairs(derivation.pairs, redirectSyncModels)
|
||||
},
|
||||
[applyMappingPairs, redirectSyncModels]
|
||||
[requestMappingDraft, openRedirectPanel, raiseMappingDraft]
|
||||
)
|
||||
|
||||
// Handle successful submission
|
||||
@@ -2148,6 +2163,32 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)
|
||||
|
||||
const tlsInsecureSkipVerifyFields = (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tls_insecure_skip_verify'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between px-4 py-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>{t('Skip Upstream TLS Verification')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={sensitiveLocked}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
const routingFields = (
|
||||
<div
|
||||
role='group'
|
||||
@@ -2461,7 +2502,6 @@ export function ChannelMutateDrawer({
|
||||
? () =>
|
||||
setBatchMapping({
|
||||
source: batchMappingSource,
|
||||
selected: [],
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -3081,6 +3121,38 @@ export function ChannelMutateDrawer({
|
||||
<ChannelModelsSection>
|
||||
<div className='space-y-5'>
|
||||
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
|
||||
{currentType === CHANNEL_TYPE_NEW_API &&
|
||||
canBindTaskPlugin &&
|
||||
taskPluginOptionsQuery.isSuccess &&
|
||||
!showProviderPicker && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='task_extend_plugin_keys'
|
||||
render={({ field }) => (
|
||||
<FormItem className='mb-4'>
|
||||
<FormLabel>{t('Upstream task plugins')}</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={taskPluginExtensionOptions}
|
||||
selected={field.value ?? []}
|
||||
onChange={handleTaskExtendPluginKeysChange}
|
||||
placeholder={t(
|
||||
'Select the task plugins installed on the upstream gateway'
|
||||
)}
|
||||
maxVisibleChips={8}
|
||||
disabled={!canEditSensitive}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'This channel serves the models of every selected plugin. The upstream New API gateway must have the same plugins installed.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='models'
|
||||
@@ -3328,9 +3400,6 @@ export function ChannelMutateDrawer({
|
||||
onClick={() =>
|
||||
setBatchMapping({
|
||||
source: 'upstream',
|
||||
selected: discovery.models.filter((model) =>
|
||||
currentModelsArray.includes(model)
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -3648,6 +3717,54 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
|
||||
{/* AWS (type 33) */}
|
||||
{currentType === 33 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='aws_api_type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('AWS API')}</FormLabel>
|
||||
<Select
|
||||
disabled={sensitiveLocked}
|
||||
items={[
|
||||
{
|
||||
value: 'invoke_model',
|
||||
label: t('InvokeModel (legacy)'),
|
||||
},
|
||||
{
|
||||
value: 'mantle',
|
||||
label: t('Mantle (Messages API)'),
|
||||
},
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? 'invoke_model'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='invoke_model'>
|
||||
{t('InvokeModel (legacy)')}
|
||||
</SelectItem>
|
||||
<SelectItem value='mantle'>
|
||||
{t('Mantle (Messages API)')}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Use Mantle for supported Claude models and automatic prompt caching. Keep InvokeModel for older models and Amazon Nova.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{currentType === 33 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -3852,6 +3969,29 @@ export function ChannelMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{vertexKeyType === 'api_key' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='vertex_project_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Vertex AI Project ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., my-project-123')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Optional. Without it, Google picks the region; with it, requests go to the deployment region below.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{vertexKeyType === 'json' && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Service account JSON file(s)')}</FormLabel>
|
||||
@@ -4654,6 +4794,7 @@ export function ChannelMutateDrawer({
|
||||
{proxyFields}
|
||||
{httpProtocolFields}
|
||||
{httpShardsFields}
|
||||
{tlsInsecureSkipVerifyFields}
|
||||
</fieldset>
|
||||
</div>
|
||||
{upstreamModelDetectionFields}
|
||||
@@ -4920,14 +5061,9 @@ export function ChannelMutateDrawer({
|
||||
mappingValue={formValues.model_mapping || ''}
|
||||
onMappingChange={handlePanelMappingChange}
|
||||
onMappingCommit={handlePanelMappingCommit}
|
||||
suggestions={namingSuggestions}
|
||||
onApplySuggestion={handleApplySuggestion}
|
||||
onOpenRules={() =>
|
||||
onBatchAdd={() =>
|
||||
setBatchMapping({
|
||||
source: 'upstream',
|
||||
selected: upstreamModelList.filter((model) =>
|
||||
currentModelsArray.includes(model)
|
||||
),
|
||||
source: batchMappingSource,
|
||||
})
|
||||
}
|
||||
syncModels={redirectSyncModels}
|
||||
@@ -4967,7 +5103,6 @@ export function ChannelMutateDrawer({
|
||||
upstreamModels={upstreamModelList}
|
||||
channelModels={currentModelsArray}
|
||||
initialSource={batchMapping.source}
|
||||
initialSelected={batchMapping.selected}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setBatchMapping(null)
|
||||
}}
|
||||
|
||||
@@ -16,16 +16,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { type ReactNode, useId, useMemo, useState } from 'react'
|
||||
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
import {
|
||||
@@ -36,8 +51,6 @@ import {
|
||||
} from '../lib'
|
||||
import { UpstreamModelSelection } from './upstream-model-selection'
|
||||
|
||||
const PREVIEW_LIMIT = 8
|
||||
|
||||
/** Where the selectable names come from. */
|
||||
export type ModelMappingBatchSource = 'upstream' | 'channel'
|
||||
|
||||
@@ -47,16 +60,12 @@ export type ModelMappingBatchResult = {
|
||||
syncModels: boolean
|
||||
}
|
||||
|
||||
type RuleType = ModelMappingRule['type']
|
||||
|
||||
type ModelMappingBatchDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Names returned by the provider's model list; enables the upstream source. */
|
||||
upstreamModels?: string[]
|
||||
channelModels: string[]
|
||||
initialSource?: ModelMappingBatchSource
|
||||
initialSelected?: string[]
|
||||
onApply: (result: ModelMappingBatchResult) => void
|
||||
}
|
||||
|
||||
@@ -65,153 +74,48 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
const id = useId()
|
||||
const upstreamModels = props.upstreamModels ?? []
|
||||
const hasUpstream = upstreamModels.length > 0
|
||||
const [source, setSource] = useState<ModelMappingBatchSource>(
|
||||
() => props.initialSource ?? (hasUpstream ? 'upstream' : 'channel')
|
||||
)
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
props.initialSelected ?? []
|
||||
const [source, setSource] = useState<ModelMappingBatchSource>(() =>
|
||||
hasUpstream ? (props.initialSource ?? 'upstream') : 'channel'
|
||||
)
|
||||
// Opening a batch action never opts models into a change on the user's behalf.
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [direction, setDirection] = useState<ModelMappingDirection>(
|
||||
source === 'upstream' ? 'upstream' : 'request'
|
||||
)
|
||||
const [ruleType, setRuleType] = useState<RuleType>(
|
||||
const [ruleType, setRuleType] = useState<ModelMappingRule['type']>(
|
||||
direction === 'upstream' ? 'strip-suffix' : 'add-suffix'
|
||||
)
|
||||
const [stripPrefixes, setStripPrefixes] = useState('')
|
||||
const [stripSuffixes, setStripSuffixes] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [suffix, setSuffix] = useState('')
|
||||
const [affix, setAffix] = useState('')
|
||||
const [find, setFind] = useState('')
|
||||
const [replaceWith, setReplaceWith] = useState('')
|
||||
const [syncModels, setSyncModels] = useState(true)
|
||||
|
||||
const candidates =
|
||||
source === 'upstream' ? upstreamModels : props.channelModels
|
||||
const rule = useMemo<ModelMappingRule>(() => {
|
||||
if (ruleType === 'strip-prefix') {
|
||||
return { type: 'strip-prefix', values: stripPrefixes }
|
||||
if (ruleType === 'strip-prefix' || ruleType === 'strip-suffix') {
|
||||
return { type: ruleType, values: affix }
|
||||
}
|
||||
if (ruleType === 'strip-suffix') {
|
||||
return { type: 'strip-suffix', values: stripSuffixes }
|
||||
if (ruleType === 'add-prefix' || ruleType === 'add-suffix') {
|
||||
return { type: ruleType, value: affix }
|
||||
}
|
||||
if (ruleType === 'add-prefix') return { type: 'add-prefix', value: prefix }
|
||||
if (ruleType === 'add-suffix') return { type: 'add-suffix', value: suffix }
|
||||
return { type: 'replace', find, replaceWith }
|
||||
}, [
|
||||
ruleType,
|
||||
stripPrefixes,
|
||||
stripSuffixes,
|
||||
prefix,
|
||||
suffix,
|
||||
find,
|
||||
replaceWith,
|
||||
])
|
||||
}, [ruleType, affix, find, replaceWith])
|
||||
const derivation = useMemo(
|
||||
() => deriveModelMappingPairs(selected, direction, rule),
|
||||
[selected, direction, rule]
|
||||
)
|
||||
const preview = derivation.pairs.slice(0, PREVIEW_LIMIT)
|
||||
const remaining = derivation.pairs.length - preview.length
|
||||
|
||||
const changeDirection = (next: ModelMappingDirection) => {
|
||||
setDirection(next)
|
||||
setRuleType(next === 'upstream' ? 'strip-suffix' : 'add-suffix')
|
||||
}
|
||||
|
||||
const changeSource = (next: ModelMappingBatchSource) => {
|
||||
setSource(next)
|
||||
setSelected([])
|
||||
changeDirection(next === 'upstream' ? 'upstream' : 'request')
|
||||
}
|
||||
|
||||
const replaceControls = (
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<Input
|
||||
aria-label={t('Find')}
|
||||
placeholder='.'
|
||||
value={find}
|
||||
onChange={(event) => setFind(event.target.value)}
|
||||
disabled={ruleType !== 'replace'}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t('Replace with')}
|
||||
placeholder='-'
|
||||
value={replaceWith}
|
||||
onChange={(event) => setReplaceWith(event.target.value)}
|
||||
disabled={ruleType !== 'replace'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const affixHint = t('Separate several values with commas')
|
||||
let ruleOptions: Array<{
|
||||
type: RuleType
|
||||
label: string
|
||||
control: ReactNode
|
||||
hint?: string
|
||||
}>
|
||||
if (direction === 'upstream') {
|
||||
ruleOptions = [
|
||||
{
|
||||
type: 'strip-suffix',
|
||||
label: t('Strip suffix'),
|
||||
hint: affixHint,
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Suffix')}
|
||||
placeholder='-all, -latest'
|
||||
value={stripSuffixes}
|
||||
onChange={(event) => setStripSuffixes(event.target.value)}
|
||||
disabled={ruleType !== 'strip-suffix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'strip-prefix',
|
||||
label: t('Strip prefix'),
|
||||
hint: affixHint,
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Prefix')}
|
||||
placeholder='openai/, anthropic/'
|
||||
value={stripPrefixes}
|
||||
onChange={(event) => setStripPrefixes(event.target.value)}
|
||||
disabled={ruleType !== 'strip-prefix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: 'replace', label: t('Replace text'), control: replaceControls },
|
||||
const hasRule =
|
||||
ruleType === 'replace' ? find.length > 0 : affix.trim().length > 0
|
||||
const isPrefix = ruleType === 'add-prefix' || ruleType === 'strip-prefix'
|
||||
const isStrip = ruleType === 'strip-prefix' || ruleType === 'strip-suffix'
|
||||
const ruleOptions: Array<{ value: ModelMappingRule['type']; label: string }> =
|
||||
[
|
||||
{ value: 'strip-suffix', label: t('Strip suffix') },
|
||||
{ value: 'strip-prefix', label: t('Strip prefix') },
|
||||
{ value: 'add-suffix', label: t('Add suffix') },
|
||||
{ value: 'add-prefix', label: t('Add prefix') },
|
||||
{ value: 'replace', label: t('Replace text') },
|
||||
]
|
||||
} else {
|
||||
ruleOptions = [
|
||||
{
|
||||
type: 'add-suffix',
|
||||
label: t('Add suffix'),
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Suffix')}
|
||||
placeholder='-all'
|
||||
value={suffix}
|
||||
onChange={(event) => setSuffix(event.target.value)}
|
||||
disabled={ruleType !== 'add-suffix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'add-prefix',
|
||||
label: t('Add prefix'),
|
||||
control: (
|
||||
<Input
|
||||
aria-label={t('Prefix')}
|
||||
placeholder='openai/'
|
||||
value={prefix}
|
||||
onChange={(event) => setPrefix(event.target.value)}
|
||||
disabled={ruleType !== 'add-prefix'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: 'replace', label: t('Replace text'), control: replaceControls },
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -219,9 +123,10 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={t('Batch add mappings')}
|
||||
description={t(
|
||||
'Pick models and derive the other side with a rule to create request-to-upstream mappings.'
|
||||
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
||||
)}
|
||||
contentClassName='sm:max-w-4xl'
|
||||
bodyClassName='flex flex-col gap-5'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@@ -233,7 +138,7 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
disabled={derivation.pairs.length === 0}
|
||||
disabled={!hasRule || derivation.pairs.length === 0}
|
||||
onClick={() => {
|
||||
props.onApply({
|
||||
pairs: derivation.pairs,
|
||||
@@ -247,24 +152,102 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
<section aria-labelledby={`${id}-models`} className='min-w-0 space-y-3'>
|
||||
<FieldSet>
|
||||
<FieldLegend id={`${id}-task`} variant='label'>
|
||||
{t('What do you want to change?')}
|
||||
</FieldLegend>
|
||||
<RadioGroup
|
||||
aria-labelledby={`${id}-task`}
|
||||
value={direction}
|
||||
className='sm:grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'upstream' && value !== 'request') return
|
||||
setDirection(value)
|
||||
setRuleType(value === 'upstream' ? 'strip-suffix' : 'add-suffix')
|
||||
setAffix('')
|
||||
setFind('')
|
||||
setReplaceWith('')
|
||||
}}
|
||||
>
|
||||
<FieldLabel htmlFor={`${id}-aliases`}>
|
||||
<Field orientation='horizontal'>
|
||||
<RadioGroupItem
|
||||
id={`${id}-aliases`}
|
||||
value='upstream'
|
||||
aria-labelledby={`${id}-aliases-label`}
|
||||
aria-describedby={`${id}-aliases-hint`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<span id={`${id}-aliases-label`}>
|
||||
{t('Create aliases for users')}
|
||||
</span>
|
||||
<FieldDescription id={`${id}-aliases-hint`}>
|
||||
{t('Keep upstream names; choose what users call.')}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor={`${id}-upstream`}>
|
||||
<Field orientation='horizontal'>
|
||||
<RadioGroupItem
|
||||
id={`${id}-upstream`}
|
||||
value='request'
|
||||
aria-labelledby={`${id}-upstream-label`}
|
||||
aria-describedby={`${id}-upstream-hint`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<span id={`${id}-upstream-label`}>
|
||||
{t('Change upstream model names')}
|
||||
</span>
|
||||
<FieldDescription id={`${id}-upstream-hint`}>
|
||||
{t(
|
||||
'Keep the names users call; change what is sent upstream.'
|
||||
)}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
<div className='grid gap-6 md:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby={`${id}-models`}
|
||||
className='flex min-w-0 flex-col gap-3'
|
||||
>
|
||||
<h3 id={`${id}-models`} className='text-sm font-semibold'>
|
||||
{t('Select models')}
|
||||
{direction === 'upstream'
|
||||
? t('1. Select upstream models')
|
||||
: t('1. Select models users call')}
|
||||
</h3>
|
||||
{hasUpstream && (
|
||||
<Tabs
|
||||
value={source}
|
||||
onValueChange={(value) =>
|
||||
changeSource(value === 'channel' ? 'channel' : 'upstream')
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'channel' && value !== 'upstream') return
|
||||
setSource(value)
|
||||
setSelected([])
|
||||
}}
|
||||
>
|
||||
<TabsList variant='line' aria-label={t('Select models')}>
|
||||
<TabsTrigger value='upstream'>
|
||||
{t('Upstream model list')} ({upstreamModels.length})
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='grid w-full grid-cols-2 group-data-horizontal/tabs:h-auto'
|
||||
aria-label={t('Select models')}
|
||||
>
|
||||
<TabsTrigger
|
||||
value='upstream'
|
||||
className='h-auto min-h-8 whitespace-normal'
|
||||
>
|
||||
<span>
|
||||
{t('Upstream model list')} ({upstreamModels.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='channel'>
|
||||
{t('Channel models')} ({props.channelModels.length})
|
||||
<TabsTrigger
|
||||
value='channel'
|
||||
className='h-auto min-h-8 whitespace-normal'
|
||||
>
|
||||
<span>
|
||||
{t('Channel models')} ({props.channelModels.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -283,161 +266,138 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<div className='min-w-0 space-y-5'>
|
||||
<section aria-labelledby={`${id}-direction`} className='space-y-2'>
|
||||
<h3 id={`${id}-direction`} className='text-sm font-semibold'>
|
||||
{t('Selected names are')}
|
||||
</h3>
|
||||
<RadioGroup
|
||||
value={direction}
|
||||
aria-labelledby={`${id}-direction`}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'upstream' || value === 'request') {
|
||||
changeDirection(value)
|
||||
}
|
||||
}}
|
||||
className='gap-2'
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value='upstream'
|
||||
id={`${id}-direction-upstream`}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<Label
|
||||
htmlFor={`${id}-direction-upstream`}
|
||||
className='font-normal'
|
||||
>
|
||||
{t('Upstream names returned by the provider')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Derives the request names your users call')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value='request'
|
||||
id={`${id}-direction-request`}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<Label
|
||||
htmlFor={`${id}-direction-request`}
|
||||
className='font-normal'
|
||||
>
|
||||
{t('Request names your users call')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Derives the upstream names sent to the provider')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</section>
|
||||
<section aria-labelledby={`${id}-rule`} className='space-y-3'>
|
||||
<h3 id={`${id}-rule`} className='text-sm font-semibold'>
|
||||
{t('Rule')}
|
||||
</h3>
|
||||
<RadioGroup
|
||||
value={ruleType}
|
||||
aria-labelledby={`${id}-rule`}
|
||||
onValueChange={(value) => {
|
||||
const option = ruleOptions.find((item) => item.type === value)
|
||||
if (option) setRuleType(option.type)
|
||||
}}
|
||||
className='gap-3'
|
||||
>
|
||||
{ruleOptions.map((option) => (
|
||||
<div key={option.type} className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value={option.type}
|
||||
id={`${id}-rule-${option.type}`}
|
||||
className='mt-2'
|
||||
/>
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
<Label
|
||||
htmlFor={`${id}-rule-${option.type}`}
|
||||
className='font-normal'
|
||||
>
|
||||
<section
|
||||
aria-labelledby={`${id}-naming`}
|
||||
className='flex min-w-0 flex-col gap-3'
|
||||
>
|
||||
<h3 id={`${id}-naming`} className='text-sm font-semibold'>
|
||||
{direction === 'upstream'
|
||||
? t('2. Set names users call')
|
||||
: t('2. Set upstream model names')}
|
||||
</h3>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-rule`}>{t('Rule')}</FieldLabel>
|
||||
<Select
|
||||
items={ruleOptions}
|
||||
value={ruleType}
|
||||
onValueChange={(value) => {
|
||||
const option = ruleOptions.find(
|
||||
(item) => item.value === value
|
||||
)
|
||||
if (option) {
|
||||
setRuleType(option.value)
|
||||
setAffix('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-rule`} className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ruleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Label>
|
||||
{option.control}
|
||||
{option.hint && ruleType === option.type && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{option.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</section>
|
||||
{direction === 'upstream' && (
|
||||
<div className='flex items-start gap-3 rounded-md border p-3'>
|
||||
<Checkbox
|
||||
id={`${id}-sync`}
|
||||
className='mt-0.5'
|
||||
checked={syncModels}
|
||||
onCheckedChange={(checked) => setSyncModels(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${id}-sync`} className='leading-5 font-normal'>
|
||||
{t(
|
||||
'Publish the request names and remove the raw upstream names from the model list'
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{ruleType === 'replace' ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-find`}>{t('Find')}</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-find`}
|
||||
placeholder='.'
|
||||
value={find}
|
||||
onChange={(event) => setFind(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-replacement`}>
|
||||
{t('Replace with')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-replacement`}
|
||||
placeholder='-'
|
||||
value={replaceWith}
|
||||
onChange={(event) => setReplaceWith(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-affix`}>
|
||||
{isPrefix ? t('Prefix') : t('Suffix')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-affix`}
|
||||
placeholder={isPrefix ? 'openai/' : '-all'}
|
||||
value={affix}
|
||||
onChange={(event) => setAffix(event.target.value)}
|
||||
/>
|
||||
{isStrip && (
|
||||
<FieldDescription>
|
||||
{t('Separate several values with commas')}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<section aria-labelledby={`${id}-preview`} className='space-y-2'>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<section
|
||||
aria-labelledby={`${id}-preview`}
|
||||
className='mt-2 flex min-w-0 flex-col gap-3 rounded-lg border p-3'
|
||||
>
|
||||
<h3 id={`${id}-preview`} className='text-sm font-semibold'>
|
||||
{t('Preview')}
|
||||
{t('3. Review mappings')}
|
||||
</h3>
|
||||
{selected.length === 0 && (
|
||||
<div className='text-muted-foreground grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 text-xs'>
|
||||
<span>{t('Users call')}</span>
|
||||
<span aria-hidden='true'>→</span>
|
||||
<span>{t('Upstream receives')}</span>
|
||||
</div>
|
||||
{derivation.pairs.length > 0 && hasRule && (
|
||||
<ul
|
||||
aria-label={t('Preview')}
|
||||
className='flex max-h-56 flex-col gap-2 overflow-y-auto font-mono text-xs'
|
||||
>
|
||||
{derivation.pairs.map((pair) => (
|
||||
<li
|
||||
key={pair.from}
|
||||
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
|
||||
>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className='size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{(!hasRule || selected.length === 0) && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Select at least one model to preview mappings.')}
|
||||
{selected.length === 0
|
||||
? t('Select at least one model to preview mappings.')
|
||||
: t('Enter a rule to preview mappings.')}
|
||||
</p>
|
||||
)}
|
||||
{derivation.pairs.length > 0 && (
|
||||
<div className='rounded-md border p-3 font-mono text-xs'>
|
||||
<div className='text-muted-foreground mb-2 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 font-sans'>
|
||||
<span>{t('Request Model Name')}</span>
|
||||
<span className='w-3.5' />
|
||||
<span>{t('Upstream Model Name')}</span>
|
||||
</div>
|
||||
<ul
|
||||
aria-label={t('Preview')}
|
||||
className='max-h-56 space-y-1 overflow-y-auto'
|
||||
>
|
||||
{preview.map((pair) => (
|
||||
<li
|
||||
key={pair.to}
|
||||
className='grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2'
|
||||
>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.from}</span>
|
||||
<ArrowRight
|
||||
className='mt-0.5 size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='min-w-0 wrap-anywhere'>{pair.to}</span>
|
||||
</li>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<li className='text-muted-foreground'>
|
||||
{t('+{{count}} more', { count: remaining })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{selected.length === 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Example: users call gpt-4o; upstream receives gpt-4o-all.')}
|
||||
</p>
|
||||
)}
|
||||
{derivation.unchanged.length > 0 && (
|
||||
{hasRule && derivation.unchanged.length > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('{{count}} model(s) unchanged by the rule were skipped', {
|
||||
count: derivation.unchanged.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{derivation.conflicts.length > 0 && (
|
||||
{hasRule && derivation.conflicts.length > 0 && (
|
||||
<p className='text-warning text-xs'>
|
||||
{t(
|
||||
'{{count}} model(s) skipped because another model derives the same request name',
|
||||
@@ -446,7 +406,24 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) {
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{direction === 'upstream' && (
|
||||
<Field orientation='horizontal'>
|
||||
<Checkbox
|
||||
id={`${id}-sync`}
|
||||
checked={syncModels}
|
||||
onCheckedChange={(checked) => setSyncModels(checked === true)}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor={`${id}-sync`}
|
||||
className='leading-5 font-normal'
|
||||
>
|
||||
{t(
|
||||
'Publish the request names and remove the raw upstream names from the model list'
|
||||
)}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -434,6 +434,11 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
)}
|
||||
|
||||
<TabsContent value='visual' className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{showFilter && (
|
||||
|
||||
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -25,11 +24,9 @@ import {
|
||||
type FloatingWindowPosition,
|
||||
} from '@/components/floating-window'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import type { ModelNamingSuggestion } from '../lib'
|
||||
import {
|
||||
ModelMappingEditor,
|
||||
type ModelMappingDraftRequest,
|
||||
@@ -44,9 +41,7 @@ type ModelRedirectPanelProps = {
|
||||
onMappingChange: (value: string) => void
|
||||
/** Called once an edit settles; the drawer syncs the model list here. */
|
||||
onMappingCommit: (value: string) => void
|
||||
suggestions: ModelNamingSuggestion[]
|
||||
onApplySuggestion: (suggestion: ModelNamingSuggestion) => void
|
||||
onOpenRules: () => void
|
||||
onBatchAdd: () => void
|
||||
syncModels: boolean
|
||||
onSyncModelsChange: (value: boolean) => void
|
||||
sourceModelOptions: string[]
|
||||
@@ -77,6 +72,7 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
|
||||
defaultWidth={props.width}
|
||||
minWidth={340}
|
||||
storageKey='channel-model-redirects'
|
||||
expandRequest={props.draftRequest?.token}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<div className='flex items-start gap-3'>
|
||||
@@ -99,65 +95,17 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
{props.suggestions.length > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Suggested rules')}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{props.suggestions.map((suggestion) => {
|
||||
let label = ''
|
||||
if (suggestion.rule.type === 'replace') {
|
||||
label = t('Replace {{find}} with {{replaceWith}}', {
|
||||
find: suggestion.rule.find,
|
||||
replaceWith: suggestion.rule.replaceWith,
|
||||
})
|
||||
} else if ('values' in suggestion.rule) {
|
||||
label = t('Strip {{affix}}', {
|
||||
affix: suggestion.rule.values,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='xs'
|
||||
disabled={props.disabled}
|
||||
onClick={() => props.onApplySuggestion(suggestion)}
|
||||
>
|
||||
<Wand2 aria-hidden='true' />
|
||||
{label}
|
||||
<Badge variant='outline' className='h-4 px-1 text-[10px]'>
|
||||
{suggestion.models.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='xs'
|
||||
disabled={props.disabled}
|
||||
onClick={props.onOpenRules}
|
||||
>
|
||||
{t('More rules')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ModelMappingEditor
|
||||
value={props.mappingValue}
|
||||
onChange={props.onMappingChange}
|
||||
onCommit={props.onMappingCommit}
|
||||
disabled={props.disabled}
|
||||
sourceModelOptions={props.sourceModelOptions}
|
||||
targetModelOptions={props.targetModelOptions}
|
||||
draftRequest={props.draftRequest}
|
||||
onDraftRequestHandled={props.onDraftRequestHandled}
|
||||
/>
|
||||
</div>
|
||||
<ModelMappingEditor
|
||||
value={props.mappingValue}
|
||||
onChange={props.onMappingChange}
|
||||
onCommit={props.onMappingCommit}
|
||||
onBatchAdd={props.onBatchAdd}
|
||||
disabled={props.disabled}
|
||||
sourceModelOptions={props.sourceModelOptions}
|
||||
targetModelOptions={props.targetModelOptions}
|
||||
draftRequest={props.draftRequest}
|
||||
onDraftRequestHandled={props.onDraftRequestHandled}
|
||||
/>
|
||||
</FloatingWindow>
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,7 @@ export function NumericSpinnerInput({
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
data-slot='numeric-spinner-value'
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={localValue}
|
||||
@@ -178,6 +179,7 @@ export function NumericSpinnerInput({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
data-slot='numeric-spinner-value'
|
||||
type='button'
|
||||
onClick={handleStartEdit}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -72,7 +72,7 @@ function ModelCategory(props: ModelCategoryProps) {
|
||||
const allSelected = selectedCount === props.models.length
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen className='rounded-lg border'>
|
||||
<Collapsible defaultOpen className='@container rounded-lg border'>
|
||||
<div className='flex items-center gap-3 px-3'>
|
||||
<Checkbox
|
||||
aria-label={t('Select all models in {{category}}', {
|
||||
@@ -109,7 +109,7 @@ function ModelCategory(props: ModelCategoryProps) {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className='border-t px-3 py-2'>
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<div className='grid gap-2 @lg:grid-cols-2'>
|
||||
{props.models.map((model) => (
|
||||
<div key={model} className='flex min-w-0 items-start gap-2'>
|
||||
<Checkbox
|
||||
|
||||
@@ -16,6 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export const CHANNEL_PRIORITY_GUIDE_KEYS = {
|
||||
card: 'channels:priority-drag-guide:card:v1',
|
||||
table: 'channels:priority-drag-guide:v1',
|
||||
} as const
|
||||
// ============================================================================
|
||||
// Channel Types (from constant/channel.go)
|
||||
// All label/name values are i18n keys; use t(value) when displaying.
|
||||
@@ -84,7 +89,7 @@ export const CHANNEL_TYPES = {
|
||||
51: 'Jimeng',
|
||||
52: 'Vidu',
|
||||
53: 'Submodel',
|
||||
54: 'DoubaoVideo',
|
||||
54: 'Doubao',
|
||||
55: 'Sora',
|
||||
56: 'Replicate',
|
||||
57: 'ChatGPT Subscription (Codex)',
|
||||
@@ -157,7 +162,8 @@ export const CHANNEL_PROVIDER_PRESENTATION: Partial<
|
||||
52: { descriptionKey: 'Connect to Vidu video generation services' },
|
||||
53: { descriptionKey: 'Connect to Submodel model services' },
|
||||
54: {
|
||||
descriptionKey: 'Generate Doubao Seedance videos through Volcengine Ark',
|
||||
descriptionKey:
|
||||
'Connect to Doubao Seedance video and Seedream image generation through Volcengine Ark',
|
||||
},
|
||||
55: { descriptionKey: 'Connect to OpenAI Sora video generation services' },
|
||||
56: { descriptionKey: 'Access hosted model predictions through Replicate' },
|
||||
@@ -171,7 +177,8 @@ export const CHANNEL_PROVIDER_PRESENTATION: Partial<
|
||||
},
|
||||
59: { descriptionKey: 'Connect to model services through a Sub2API gateway' },
|
||||
60: {
|
||||
descriptionKey: 'Connect to model services from another New API instance',
|
||||
descriptionKey:
|
||||
'Connect to New API model services with support for multiple task plugins',
|
||||
},
|
||||
62: { descriptionKey: 'Connect to self-hosted models served by vLLM' },
|
||||
63: { descriptionKey: 'Connect to self-hosted models served by SGLang' },
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
animate,
|
||||
type AnimationPlaybackControls,
|
||||
type ValueAnimationTransition,
|
||||
} from 'motion'
|
||||
import { useReducedMotion } from 'motion/react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
previewPriorityDrop,
|
||||
type PriorityDrop,
|
||||
type PriorityPosition,
|
||||
} from '../lib/channel-priority-order'
|
||||
import type { Channel } from '../types'
|
||||
import type { useChannelPriorityOrder } from './use-channel-priority-order'
|
||||
|
||||
function scrollParent(element: HTMLElement): HTMLElement {
|
||||
let parent = element.parentElement
|
||||
while (parent) {
|
||||
if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY)) return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return document.documentElement
|
||||
}
|
||||
|
||||
function animateDragTransform(
|
||||
element: HTMLElement,
|
||||
transform: string,
|
||||
transition: ValueAnimationTransition<string>
|
||||
): AnimationPlaybackControls {
|
||||
// Animate the value directly so Motion cannot reapply a DOM transform after
|
||||
// cleanup and React's final reorder have restored the row's natural position.
|
||||
return animate(
|
||||
element.style.transform || 'translate3d(0px, 0px, 0px)',
|
||||
transform,
|
||||
{
|
||||
...transition,
|
||||
onUpdate: (value) => {
|
||||
element.style.transform = value
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type DragOptions = {
|
||||
items: Channel[]
|
||||
desc: boolean
|
||||
enabled: boolean
|
||||
scope: string
|
||||
begin: () => void
|
||||
select: (drop: PriorityDrop | null) => void
|
||||
commit: () => Promise<void>
|
||||
cancel: () => void
|
||||
previewStore: ReturnType<typeof useChannelPriorityOrder>['previewStore']
|
||||
}
|
||||
|
||||
type DragSlot = {
|
||||
id: number
|
||||
element: HTMLElement
|
||||
rect: DOMRect
|
||||
transform: string
|
||||
minHeight: string
|
||||
willChange: string
|
||||
boxShadow: string
|
||||
backgroundColor: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type DragPoint = {
|
||||
pointerId: number
|
||||
id: number
|
||||
startX: number
|
||||
startY: number
|
||||
x: number
|
||||
y: number
|
||||
moved: boolean
|
||||
clone: HTMLElement | null
|
||||
overlay: HTMLElement | null
|
||||
feedback: HTMLElement | null
|
||||
insertion: HTMLElement | null
|
||||
insertionY: number | null
|
||||
drop: PriorityDrop | null
|
||||
source: HTMLElement
|
||||
opacity: string
|
||||
outline: string
|
||||
scroller: HTMLElement
|
||||
scrollBounds: DOMRect
|
||||
rootBounds: DOMRect
|
||||
target: string
|
||||
slots: DragSlot[]
|
||||
slotsByID: Map<number, DragSlot>
|
||||
items: Channel[]
|
||||
ordered: Channel[]
|
||||
hitIndex: number
|
||||
hitTarget: number | null
|
||||
horizontal: boolean
|
||||
table: boolean
|
||||
destination: DOMRect | null
|
||||
scrollTop: number
|
||||
scrollLeft: number
|
||||
lastFrame: number
|
||||
commit: boolean
|
||||
}
|
||||
|
||||
/** The gesture keeps React rows and DOM order fixed. Only transforms change. */
|
||||
export function useChannelPriorityDrag(options: DragOptions) {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const point = useRef<DragPoint | null>(null)
|
||||
const [feedbackContainer, setFeedbackContainer] =
|
||||
useState<HTMLElement | null>(null)
|
||||
const frame = useRef<number | undefined>(undefined)
|
||||
const released = useRef<DragPoint | null>(null)
|
||||
const landing = useRef<AnimationPlaybackControls | null>(null)
|
||||
const animations = useRef(new Map<HTMLElement, AnimationPlaybackControls>())
|
||||
const latest = useRef(options)
|
||||
latest.current = options
|
||||
const reduced = useReducedMotion()
|
||||
const transition = useMemo(
|
||||
() =>
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, stiffness: 450, damping: 38 },
|
||||
[reduced]
|
||||
)
|
||||
|
||||
const clearVisuals = useCallback((current: DragPoint) => {
|
||||
current.overlay?.remove()
|
||||
setFeedbackContainer(null)
|
||||
current.source.style.opacity = current.opacity
|
||||
current.source.style.outline = current.outline
|
||||
for (const slot of current.slots) {
|
||||
animations.current.get(slot.element)?.stop()
|
||||
Object.assign(slot.element.style, {
|
||||
transform: slot.transform,
|
||||
minHeight: slot.minHeight,
|
||||
willChange: slot.willChange,
|
||||
boxShadow: slot.boxShadow,
|
||||
backgroundColor: slot.backgroundColor,
|
||||
})
|
||||
delete slot.element.dataset.priorityDrop
|
||||
}
|
||||
animations.current.clear()
|
||||
if (root.current) delete root.current.dataset.priorityDragging
|
||||
released.current = null
|
||||
}, [])
|
||||
|
||||
const preview = useCallback(
|
||||
(current: DragPoint, drop: PriorityDrop | null, settling = false) => {
|
||||
const ordered = drop
|
||||
? previewPriorityDrop(current.items, drop, latest.current.desc)
|
||||
: current.items
|
||||
current.ordered = ordered
|
||||
current.drop = drop
|
||||
if (current.clone) {
|
||||
current.clone.style.outline =
|
||||
drop && drop.position !== 'same'
|
||||
? '1px solid var(--warning)'
|
||||
: '1px solid var(--primary)'
|
||||
}
|
||||
const targetPriority =
|
||||
current.items.find((item) => item.id === drop?.target_id)?.priority ?? 0
|
||||
current.insertionY = null
|
||||
if (current.table && drop && drop.position !== 'same') {
|
||||
const tierIDs = new Set(
|
||||
current.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.id !== current.id &&
|
||||
(item.priority ?? 0) === targetPriority
|
||||
)
|
||||
.map((item) => item.id)
|
||||
)
|
||||
const tier = current.slots.filter((slot) => tierIDs.has(slot.id))
|
||||
const before = (drop.position === 'before') === latest.current.desc
|
||||
current.insertionY =
|
||||
(before ? tier[0]?.rect.top : tier.at(-1)?.rect.bottom) ?? null
|
||||
}
|
||||
for (const [index, item] of ordered.entries()) {
|
||||
const slot = current.slotsByID.get(item.id)
|
||||
const destination = current.slots[index]?.rect
|
||||
if (!slot || !destination) continue
|
||||
const joining =
|
||||
drop?.position === 'same' &&
|
||||
item.id !== current.id &&
|
||||
(item.priority ?? 0) === targetPriority
|
||||
if (joining) slot.element.dataset.priorityDrop = 'same'
|
||||
else delete slot.element.dataset.priorityDrop
|
||||
slot.element.style.boxShadow = joining
|
||||
? 'inset 0 0 0 2px var(--primary)'
|
||||
: slot.boxShadow
|
||||
if (current.table) {
|
||||
slot.element.style.backgroundColor = joining
|
||||
? 'color-mix(in srgb, var(--primary) 8%, var(--card))'
|
||||
: slot.backgroundColor
|
||||
}
|
||||
const x = destination.left - slot.rect.left
|
||||
const y = destination.top - slot.rect.top
|
||||
if (item.id === current.id) current.destination = destination
|
||||
// Table rows remain stable hit targets; animate the result on release.
|
||||
if (current.table && !settling) continue
|
||||
if (slot.x === x && slot.y === y) continue
|
||||
slot.x = x
|
||||
slot.y = y
|
||||
const transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
animations.current.get(slot.element)?.stop()
|
||||
if (item.id === current.id) {
|
||||
if (!current.table) slot.element.style.transform = transform
|
||||
} else if (transition.duration === 0) {
|
||||
slot.element.style.transform = transform
|
||||
} else {
|
||||
animations.current.set(
|
||||
slot.element,
|
||||
animateDragTransform(slot.element, transform, transition)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[transition]
|
||||
)
|
||||
|
||||
const stop = useCallback(
|
||||
(commit = false) => {
|
||||
const current = point.current
|
||||
if (!current) {
|
||||
if (released.current && !commit) {
|
||||
released.current.commit = false
|
||||
landing.current?.complete()
|
||||
}
|
||||
return
|
||||
}
|
||||
point.current = null
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
|
||||
frame.current = undefined
|
||||
if (root.current?.hasPointerCapture(current.pointerId)) {
|
||||
root.current.releasePointerCapture(current.pointerId)
|
||||
}
|
||||
if (!current.moved || !current.clone) return
|
||||
if (current.feedback) current.feedback.hidden = true
|
||||
if (current.insertion) current.insertion.hidden = true
|
||||
current.commit = commit && !!current.target
|
||||
released.current = current
|
||||
if (transition.duration === 0) {
|
||||
clearVisuals(current)
|
||||
if (current.commit) void latest.current.commit()
|
||||
else latest.current.cancel()
|
||||
return
|
||||
}
|
||||
if (current.table) {
|
||||
if (current.overlay) current.overlay.hidden = true
|
||||
if (!current.commit) {
|
||||
clearVisuals(current)
|
||||
latest.current.cancel()
|
||||
return
|
||||
}
|
||||
current.source.style.opacity = current.opacity
|
||||
preview(current, current.drop, true)
|
||||
const source = current.slotsByID.get(current.id)
|
||||
if (!source) {
|
||||
clearVisuals(current)
|
||||
latest.current.cancel()
|
||||
return
|
||||
}
|
||||
landing.current = animateDragTransform(
|
||||
current.source,
|
||||
`translate3d(${source.x}px, ${source.y}px, 0)`,
|
||||
{
|
||||
...transition,
|
||||
onComplete: () => {
|
||||
clearVisuals(current)
|
||||
if (current.commit) void latest.current.commit()
|
||||
else latest.current.cancel()
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!current.commit) preview(current, null)
|
||||
const origin = current.slotsByID.get(current.id)?.rect
|
||||
const target = current.destination ?? origin
|
||||
if (!origin || !target) {
|
||||
clearVisuals(current)
|
||||
latest.current.cancel()
|
||||
return
|
||||
}
|
||||
const x =
|
||||
target.left -
|
||||
origin.left -
|
||||
current.scroller.scrollLeft +
|
||||
current.scrollLeft
|
||||
const y =
|
||||
target.top - origin.top - current.scroller.scrollTop + current.scrollTop
|
||||
landing.current = animateDragTransform(
|
||||
current.clone,
|
||||
`translate3d(${x}px, ${y}px, 0)`,
|
||||
{
|
||||
...transition,
|
||||
onComplete: () => {
|
||||
clearVisuals(current)
|
||||
// Commit changes React's order exactly once, after the visual landing.
|
||||
if (current.commit) void latest.current.commit()
|
||||
else latest.current.cancel()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[clearVisuals, preview, transition]
|
||||
)
|
||||
|
||||
const tick = useCallback(
|
||||
(time: number) => {
|
||||
frame.current = undefined
|
||||
const current = point.current
|
||||
if (!current || !root.current) return
|
||||
if (!current.moved) {
|
||||
if (
|
||||
Math.hypot(current.x - current.startX, current.y - current.startY) <=
|
||||
4
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Batch every layout read before adding the ghost or writing styles.
|
||||
const elements = [
|
||||
...root.current.querySelectorAll<HTMLElement>(
|
||||
'[data-sortable-id^="channel:"]'
|
||||
),
|
||||
]
|
||||
current.slots = elements.map((element) => ({
|
||||
id: Number(element.dataset.sortableId?.split(':')[1]),
|
||||
element,
|
||||
rect: element.getBoundingClientRect(),
|
||||
transform: element.style.transform,
|
||||
minHeight: element.style.minHeight,
|
||||
willChange: element.style.willChange,
|
||||
boxShadow: element.style.boxShadow,
|
||||
backgroundColor: element.style.backgroundColor,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}))
|
||||
current.slotsByID = new Map(
|
||||
current.slots.map((slot) => [slot.id, slot])
|
||||
)
|
||||
const origin = current.slotsByID.get(current.id)?.rect
|
||||
if (!origin) return
|
||||
current.table = current.source.tagName === 'TR'
|
||||
current.scrollBounds = current.scroller.getBoundingClientRect()
|
||||
current.rootBounds = root.current.getBoundingClientRect()
|
||||
current.scrollTop = current.scroller.scrollTop
|
||||
current.scrollLeft = current.scroller.scrollLeft
|
||||
current.horizontal =
|
||||
!current.table &&
|
||||
current.slots.some(
|
||||
(slot, index) =>
|
||||
index > 0 &&
|
||||
Math.abs(
|
||||
slot.rect.top - (current.slots[index - 1]?.rect.top ?? Infinity)
|
||||
) < 4
|
||||
)
|
||||
current.destination = origin
|
||||
current.moved = true
|
||||
latest.current.begin()
|
||||
|
||||
let clone: HTMLElement
|
||||
if (current.table) {
|
||||
clone = document.createElement('div')
|
||||
const identity = document.createElement('div')
|
||||
identity.className =
|
||||
'flex min-w-0 items-center gap-2 px-3 pt-3 text-sm'
|
||||
const name = document.createElement('span')
|
||||
name.className = 'min-w-0 flex-1 truncate font-medium'
|
||||
// Read displayed text so the preview respects the visibility toggle.
|
||||
name.textContent =
|
||||
current.source.querySelector('[data-column-id="name"]')
|
||||
?.textContent ?? ''
|
||||
const priority = document.createElement('span')
|
||||
priority.dataset.columnId = 'priority'
|
||||
priority.className =
|
||||
'text-muted-foreground shrink-0 font-mono text-xs'
|
||||
const value = document.createElement('span')
|
||||
value.dataset.slot = 'numeric-spinner-value'
|
||||
value.textContent =
|
||||
current.source.querySelector(
|
||||
'[data-column-id="priority"] [data-slot="numeric-spinner-value"]'
|
||||
)?.textContent ?? ''
|
||||
priority.append(value)
|
||||
identity.append(name, priority)
|
||||
clone.append(identity)
|
||||
} else clone = current.source.cloneNode(true) as HTMLElement
|
||||
clone.style.animation = 'none'
|
||||
clone.removeAttribute('id')
|
||||
clone.removeAttribute('data-sortable-id')
|
||||
clone
|
||||
.querySelectorAll('[id]')
|
||||
.forEach((element) => element.removeAttribute('id'))
|
||||
const ghost = clone
|
||||
ghost.dataset.originX = String(origin.left)
|
||||
ghost.dataset.originY = String(origin.top)
|
||||
Object.assign(ghost.style, {
|
||||
position: 'absolute',
|
||||
left: current.table ? '0' : `${origin.left}px`,
|
||||
top: current.table ? '0' : `${origin.top}px`,
|
||||
margin: '0',
|
||||
width: current.table ? '300px' : `${origin.width}px`,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
height: current.table ? 'auto' : `${origin.height}px`,
|
||||
pointerEvents: 'none',
|
||||
background: 'var(--card)',
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: '0 12px 28px #0003',
|
||||
outline: '1px solid var(--primary)',
|
||||
overflow: 'hidden',
|
||||
willChange: 'transform',
|
||||
})
|
||||
const overlay = document.createElement('div')
|
||||
Object.assign(overlay.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1000',
|
||||
})
|
||||
overlay.setAttribute('aria-hidden', 'true')
|
||||
overlay.inert = true
|
||||
const insertion = document.createElement('div')
|
||||
insertion.dataset.priorityInsertion = ''
|
||||
insertion.hidden = true
|
||||
Object.assign(insertion.style, {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
background: 'var(--warning)',
|
||||
borderRadius: '999px',
|
||||
boxShadow: '0 0 0 2px var(--background)',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
const feedback = document.createElement('div')
|
||||
Object.assign(feedback.style, {
|
||||
position: current.table ? 'relative' : 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
width: '300px',
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
})
|
||||
if (current.table) {
|
||||
feedback.dataset.priorityView = 'table'
|
||||
ghost.append(feedback)
|
||||
overlay.append(insertion, ghost)
|
||||
} else overlay.append(ghost, insertion, feedback)
|
||||
document.body.append(overlay)
|
||||
current.feedback = feedback
|
||||
current.insertion = insertion
|
||||
setFeedbackContainer(feedback)
|
||||
current.overlay = overlay
|
||||
current.clone = ghost
|
||||
for (const slot of current.slots) {
|
||||
slot.element.style.willChange = 'transform'
|
||||
if (slot.element.tagName !== 'TR') {
|
||||
slot.element.style.minHeight = `${slot.rect.height}px`
|
||||
}
|
||||
}
|
||||
current.source.style.opacity = '0.2'
|
||||
current.source.style.outline = '2px dashed var(--primary)'
|
||||
root.current.dataset.priorityDragging = 'true'
|
||||
}
|
||||
// No geometry reads, sorting or React table updates in the pointer frame.
|
||||
const scrollBefore = current.scroller.scrollTop
|
||||
const bounds = current.scrollBounds
|
||||
const elapsed = current.lastFrame
|
||||
? Math.min(32, time - current.lastFrame)
|
||||
: 16
|
||||
current.lastFrame = time
|
||||
if (current.x >= bounds.left && current.x <= bounds.right) {
|
||||
if (current.y < bounds.top + 32) {
|
||||
current.scroller.scrollTop -= elapsed * 0.45
|
||||
} else if (current.y > bounds.bottom - 32) {
|
||||
current.scroller.scrollTop += elapsed * 0.45
|
||||
}
|
||||
}
|
||||
const scrollX = current.scroller.scrollLeft - current.scrollLeft
|
||||
const scrollY = current.scroller.scrollTop - current.scrollTop
|
||||
const pointerX = current.x + scrollX
|
||||
const pointerY = current.y + scrollY
|
||||
let hitIndex = -1
|
||||
let distance = Infinity
|
||||
for (const [index, slot] of current.slots.entries()) {
|
||||
const dx = pointerX - slot.rect.left - slot.rect.width / 2
|
||||
const dy = pointerY - slot.rect.top - slot.rect.height / 2
|
||||
const squared = dx * dx + dy * dy
|
||||
if (squared < distance) {
|
||||
hitIndex = index
|
||||
distance = squared
|
||||
}
|
||||
}
|
||||
let next: PriorityDrop | null = null
|
||||
const slot = current.slots[hitIndex]
|
||||
if (
|
||||
slot &&
|
||||
pointerX >= slot.rect.left - 8 &&
|
||||
pointerX <= slot.rect.right + 8 &&
|
||||
pointerY >= slot.rect.top - 8 &&
|
||||
pointerY <= slot.rect.bottom + 8 &&
|
||||
current.x >= current.rootBounds.left &&
|
||||
current.x <= current.rootBounds.right &&
|
||||
current.y >= current.rootBounds.top &&
|
||||
current.y <= current.rootBounds.bottom
|
||||
) {
|
||||
if (current.table) {
|
||||
current.hitIndex = hitIndex
|
||||
current.hitTarget = slot.id === current.id ? null : slot.id
|
||||
} else if (hitIndex !== current.hitIndex) {
|
||||
current.hitIndex = hitIndex
|
||||
const occupant = current.ordered[hitIndex]
|
||||
// Resolve the card/row now occupying this slot, not its original ID.
|
||||
// A moving placeholder retains the previous target instead of resetting.
|
||||
if (occupant && occupant.id !== current.id) {
|
||||
current.hitTarget = occupant.id
|
||||
}
|
||||
}
|
||||
if (current.hitTarget !== null) {
|
||||
const offset = current.horizontal
|
||||
? pointerX - slot.rect.left
|
||||
: pointerY - slot.rect.top
|
||||
const size = current.horizontal ? slot.rect.width : slot.rect.height
|
||||
const edge = Math.min(current.table ? 10 : 24, size * 0.18)
|
||||
const leading: PriorityPosition = latest.current.desc
|
||||
? 'before'
|
||||
: 'after'
|
||||
const trailing: PriorityPosition = latest.current.desc
|
||||
? 'after'
|
||||
: 'before'
|
||||
let leadingEdge = edge
|
||||
let trailingEdge = size - edge
|
||||
if (current.drop?.target_id === current.hitTarget) {
|
||||
leadingEdge += current.drop.position === leading ? 3 : -3
|
||||
trailingEdge += current.drop.position === trailing ? -3 : 3
|
||||
}
|
||||
let position: PriorityPosition = 'same'
|
||||
if (offset < leadingEdge) position = leading
|
||||
else if (offset > trailingEdge) position = trailing
|
||||
next = { id: current.id, target_id: current.hitTarget, position }
|
||||
}
|
||||
} else {
|
||||
current.hitIndex = -1
|
||||
current.hitTarget = null
|
||||
}
|
||||
if (current.clone && !current.table) {
|
||||
current.clone.style.transform = `translate3d(${current.x - current.startX}px, ${current.y - current.startY}px, 0)`
|
||||
}
|
||||
const key = next ? `${next.target_id}:${next.position}` : ''
|
||||
if (key !== current.target) {
|
||||
current.target = key
|
||||
preview(current, next)
|
||||
latest.current.select(next)
|
||||
}
|
||||
if (current.feedback) {
|
||||
const left = Math.max(
|
||||
8,
|
||||
Math.min(current.x + 16, window.innerWidth - 308)
|
||||
)
|
||||
const top = Math.max(
|
||||
8,
|
||||
Math.min(
|
||||
current.table ? current.y - 132 : current.y + 20,
|
||||
window.innerHeight - 148
|
||||
)
|
||||
)
|
||||
const floating = current.table ? current.clone : current.feedback
|
||||
if (floating) {
|
||||
floating.style.transform = `translate3d(${left}px, ${top}px, 0)`
|
||||
}
|
||||
}
|
||||
const destination = current.destination
|
||||
if (current.insertion) {
|
||||
current.insertion.hidden =
|
||||
!current.drop || current.drop.position === 'same' || !destination
|
||||
if (!current.insertion.hidden && destination) {
|
||||
const left = destination.left - scrollX
|
||||
const top = (current.insertionY ?? destination.top) - scrollY
|
||||
const vertical = current.horizontal
|
||||
Object.assign(current.insertion.style, {
|
||||
transform: `translate3d(${vertical ? left - 6 : Math.max(left, current.scrollBounds.left)}px, ${vertical ? top : top - 2}px, 0)`,
|
||||
width: vertical
|
||||
? '4px'
|
||||
: `${Math.min(destination.right - scrollX, current.scrollBounds.right) - Math.max(left, current.scrollBounds.left)}px`,
|
||||
height: vertical ? `${destination.height}px` : '4px',
|
||||
})
|
||||
}
|
||||
}
|
||||
// Idle pointers do not run a perpetual frame loop. Continue only to scroll.
|
||||
if (current.scroller.scrollTop !== scrollBefore) {
|
||||
frame.current = requestAnimationFrame(tick)
|
||||
}
|
||||
},
|
||||
[preview]
|
||||
)
|
||||
|
||||
const start = useCallback(
|
||||
(event: PointerEvent<HTMLButtonElement>, id: number) => {
|
||||
if (
|
||||
!latest.current.enabled ||
|
||||
point.current ||
|
||||
released.current ||
|
||||
event.button !== 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
const source = root.current?.querySelector<HTMLElement>(
|
||||
`[data-sortable-id="channel:${id}"]`
|
||||
)
|
||||
if (!source || !root.current) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
point.current = {
|
||||
pointerId: event.pointerId,
|
||||
id,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
moved: false,
|
||||
clone: null,
|
||||
overlay: null,
|
||||
feedback: null,
|
||||
insertion: null,
|
||||
insertionY: null,
|
||||
drop: null,
|
||||
source,
|
||||
opacity: source.style.opacity,
|
||||
outline: source.style.outline,
|
||||
scroller: scrollParent(source),
|
||||
scrollBounds: new DOMRect(),
|
||||
rootBounds: new DOMRect(),
|
||||
target: '',
|
||||
slots: [],
|
||||
slotsByID: new Map(),
|
||||
items: latest.current.items,
|
||||
ordered: latest.current.items,
|
||||
hitIndex: -1,
|
||||
hitTarget: null,
|
||||
horizontal: false,
|
||||
table: false,
|
||||
destination: null,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
lastFrame: 0,
|
||||
commit: false,
|
||||
}
|
||||
root.current.setPointerCapture(event.pointerId)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const keyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLButtonElement>, id: number) => {
|
||||
if (event.key === 'Escape') {
|
||||
stop()
|
||||
latest.current.cancel()
|
||||
return
|
||||
}
|
||||
if (!latest.current.enabled || point.current || released.current) return
|
||||
const index = latest.current.items.findIndex((item) => item.id === id)
|
||||
let delta = 0
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1
|
||||
if (!delta) return
|
||||
event.preventDefault()
|
||||
const target = latest.current.items[index + delta]
|
||||
if (!target) return
|
||||
latest.current.begin()
|
||||
latest.current.select({
|
||||
id,
|
||||
target_id: target.id,
|
||||
position: delta < 0 === latest.current.desc ? 'before' : 'after',
|
||||
})
|
||||
void latest.current.commit()
|
||||
},
|
||||
[stop]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
options.previewStore.subscribe(() => {
|
||||
const current = point.current ?? released.current
|
||||
if (!current?.clone) return
|
||||
const { drop, preview } = options.previewStore.getSnapshot()
|
||||
let priority =
|
||||
current.items.find((item) => item.id === current.id)?.priority ?? 0
|
||||
if (drop?.id === current.id) {
|
||||
if (drop.position === 'same') {
|
||||
priority =
|
||||
current.items.find((item) => item.id === drop.target_id)
|
||||
?.priority ?? 0
|
||||
}
|
||||
if (preview) {
|
||||
priority =
|
||||
preview.items.find((item) => item.id === current.id)?.priority ??
|
||||
priority
|
||||
}
|
||||
}
|
||||
const value = current.clone.querySelector<HTMLElement>(
|
||||
'[data-column-id="priority"] [data-slot="numeric-spinner-value"]'
|
||||
)
|
||||
if (value instanceof HTMLInputElement) value.value = String(priority)
|
||||
else if (value) {
|
||||
value.textContent = String(priority)
|
||||
value.title = String(priority)
|
||||
}
|
||||
}),
|
||||
[options.previewStore]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
stop()
|
||||
}, [options.scope, options.enabled, stop])
|
||||
useEffect(() => {
|
||||
const cancel = () => stop()
|
||||
const key = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') cancel()
|
||||
}
|
||||
window.addEventListener('blur', cancel)
|
||||
window.addEventListener('resize', cancel)
|
||||
window.addEventListener('keydown', key)
|
||||
return () => {
|
||||
window.removeEventListener('blur', cancel)
|
||||
window.removeEventListener('resize', cancel)
|
||||
window.removeEventListener('keydown', key)
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
|
||||
if (released.current) released.current.commit = false
|
||||
landing.current?.stop()
|
||||
if (point.current) clearVisuals(point.current)
|
||||
if (released.current) clearVisuals(released.current)
|
||||
point.current = null
|
||||
}
|
||||
}, [clearVisuals, stop])
|
||||
|
||||
return {
|
||||
root,
|
||||
feedbackContainer,
|
||||
start,
|
||||
keyDown,
|
||||
rootEvents: {
|
||||
onPointerMove: (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (point.current?.pointerId !== event.pointerId) return
|
||||
point.current.x = event.clientX
|
||||
point.current.y = event.clientY
|
||||
if (frame.current === undefined) {
|
||||
frame.current = requestAnimationFrame(tick)
|
||||
}
|
||||
},
|
||||
onPointerUp: (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (point.current?.pointerId !== event.pointerId) return
|
||||
point.current.x = event.clientX
|
||||
point.current.y = event.clientY
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
|
||||
tick(performance.now())
|
||||
stop(true)
|
||||
},
|
||||
onPointerCancel: () => {
|
||||
if (point.current) stop()
|
||||
},
|
||||
onLostPointerCapture: () => {
|
||||
if (point.current) stop()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { getChannelPriorityOrder, reorderChannelPriority } from '../api'
|
||||
import { channelsQueryKeys } from '../lib'
|
||||
import {
|
||||
applyPriorityPreview,
|
||||
previewPriorityDrop,
|
||||
type PriorityDrop,
|
||||
type PriorityMove,
|
||||
type PriorityOrder,
|
||||
} from '../lib/channel-priority-order'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
export function useChannelPriorityOrder(props: {
|
||||
channels: Channel[]
|
||||
desc: boolean
|
||||
scope: string
|
||||
enabled: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const client = useQueryClient()
|
||||
const [optimisticDrop, setOptimisticDrop] = useState<PriorityDrop | null>(
|
||||
null
|
||||
)
|
||||
const [savedPreview, setSavedPreview] = useState<PriorityOrder | null>(null)
|
||||
// Only the status strip subscribes to hover/priority-preview updates. The table's
|
||||
// data and row objects stay unchanged throughout the pointer gesture.
|
||||
const [previewStore] = useState(() => {
|
||||
const listeners = new Set<() => void>()
|
||||
let value: {
|
||||
drop: PriorityDrop | null
|
||||
preview: PriorityOrder | null
|
||||
preparing: boolean
|
||||
} = { drop: null, preview: null, preparing: false }
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
publish: (next: typeof value) => {
|
||||
value = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
})
|
||||
const [undo, setUndo] = useState<PriorityOrder | null>(null)
|
||||
const [active, setActive] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const session = useRef<{
|
||||
snapshot: Promise<PriorityOrder>
|
||||
drop: PriorityDrop | null
|
||||
serial: number
|
||||
} | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const mutation = useMutation({
|
||||
mutationFn: reorderChannelPriority,
|
||||
meta: { errorToast: false },
|
||||
})
|
||||
const busy = useRef(false)
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
clearTimeout(timer.current)
|
||||
session.current = null
|
||||
setActive(false)
|
||||
setOptimisticDrop(null)
|
||||
setSavedPreview(null)
|
||||
previewStore.publish({ drop: null, preview: null, preparing: false })
|
||||
}, [previewStore])
|
||||
|
||||
function begin() {
|
||||
if (!props.enabled || busy.current) return
|
||||
cancel()
|
||||
// Read a fresh snapshot on every gesture, including keyboard moves.
|
||||
const snapshot = getChannelPriorityOrder()
|
||||
// Mark rejection handled until the gesture actually needs the result.
|
||||
void snapshot.catch(() => undefined)
|
||||
session.current = { snapshot, drop: null, serial: 0 }
|
||||
setActive(true)
|
||||
}
|
||||
|
||||
function select(next: PriorityDrop | null) {
|
||||
const current = session.current
|
||||
if (!current || busy.current) return
|
||||
clearTimeout(timer.current)
|
||||
current.drop = next
|
||||
current.serial++
|
||||
previewStore.publish({ drop: next, preview: null, preparing: !!next })
|
||||
if (!next) return
|
||||
const serial = current.serial
|
||||
timer.current = setTimeout(() => {
|
||||
void current.snapshot
|
||||
.then((snapshot) => {
|
||||
if (
|
||||
session.current !== current ||
|
||||
current.serial !== serial ||
|
||||
busy.current
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return reorderChannelPriority({
|
||||
...next,
|
||||
revision: snapshot.revision,
|
||||
preview: true,
|
||||
})
|
||||
})
|
||||
.then((result) => {
|
||||
if (
|
||||
result &&
|
||||
session.current === current &&
|
||||
current.serial === serial &&
|
||||
!busy.current
|
||||
) {
|
||||
previewStore.publish({
|
||||
drop: next,
|
||||
preview: result,
|
||||
preparing: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
session.current === current &&
|
||||
current.serial === serial &&
|
||||
!busy.current
|
||||
) {
|
||||
cancel()
|
||||
handleServerError(error)
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
async function save(move: PriorityMove) {
|
||||
const result = await mutation.mutateAsync(move)
|
||||
setSavedPreview(result)
|
||||
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
setUndo(move.restore ? null : result)
|
||||
if (result.changes.length > 0) toast.success(t('Channel priorities saved'))
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const current = session.current
|
||||
if (busy.current) return
|
||||
if (!current?.drop) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
busy.current = true
|
||||
setSaving(true)
|
||||
clearTimeout(timer.current)
|
||||
setOptimisticDrop(current.drop)
|
||||
previewStore.publish({ drop: current.drop, preview: null, preparing: true })
|
||||
try {
|
||||
const snapshot = await current.snapshot
|
||||
if (session.current !== current) return
|
||||
await save({ ...current.drop, revision: snapshot.revision })
|
||||
} catch (error) {
|
||||
handleServerError(error)
|
||||
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} finally {
|
||||
busy.current = false
|
||||
setSaving(false)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!undo || busy.current) return
|
||||
busy.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
await save({
|
||||
revision: undo.revision,
|
||||
restore: undo.changes.map((change) => ({
|
||||
id: change.id,
|
||||
previous: change.priority,
|
||||
priority: change.previous,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
setUndo(null)
|
||||
handleServerError(error)
|
||||
await client.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} finally {
|
||||
busy.current = false
|
||||
setSaving(false)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
cancel()
|
||||
// View/filter/page changes cancel a pending gesture, but not an in-flight save.
|
||||
}, [props.scope, props.enabled, cancel])
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(timer.current)
|
||||
session.current = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (savedPreview) {
|
||||
return applyPriorityPreview(props.channels, savedPreview, props.desc)
|
||||
}
|
||||
if (optimisticDrop) {
|
||||
return previewPriorityDrop(props.channels, optimisticDrop, props.desc)
|
||||
}
|
||||
return props.channels
|
||||
}, [props.channels, props.desc, optimisticDrop, savedPreview])
|
||||
|
||||
return {
|
||||
items,
|
||||
previewStore,
|
||||
active,
|
||||
saving,
|
||||
undo,
|
||||
begin,
|
||||
select,
|
||||
commit,
|
||||
cancel,
|
||||
restore,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import { toast } from 'sonner'
|
||||
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import {
|
||||
createServerError,
|
||||
getServerErrorMessage,
|
||||
} from '@/lib/server-error-message'
|
||||
|
||||
import { normalizeModelList } from '../lib/upstream-update-utils'
|
||||
|
||||
@@ -30,66 +34,76 @@ const upstreamUpdateRequestConfig = {
|
||||
skipErrorHandler: true,
|
||||
} satisfies ApiRequestConfig
|
||||
|
||||
function getManualIgnoredModelCount(settings: unknown): number {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object') {
|
||||
parsed = settings as Record<string, unknown>
|
||||
} else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
}
|
||||
if (!parsed) return 0
|
||||
return normalizeModelList(
|
||||
(parsed.upstream_model_update_ignored_models as unknown[]) || []
|
||||
).length
|
||||
type UpstreamUpdateChannel = { id: number; name?: string }
|
||||
|
||||
type UpstreamUpdateResult = {
|
||||
addedModels: string[]
|
||||
removedModels: string[]
|
||||
remainingModels: string[]
|
||||
remainingRemoveModels: string[]
|
||||
}
|
||||
|
||||
type UpstreamUpdateResponse<T> = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [channel, setChannel] = useState<{
|
||||
id: number
|
||||
[key: string]: unknown
|
||||
} | null>(null)
|
||||
const [channel, setChannel] = useState<UpstreamUpdateChannel | null>(null)
|
||||
const [addModels, setAddModels] = useState<string[]>([])
|
||||
const [removeModels, setRemoveModels] = useState<string[]>([])
|
||||
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
|
||||
const [applyLoading, setApplyLoading] = useState(false)
|
||||
const [detectLoading, setDetectLoading] = useState(false)
|
||||
const [detectError, setDetectError] = useState<string | null>(null)
|
||||
const [applyError, setApplyError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<UpstreamUpdateResult | null>(null)
|
||||
const [previewVersion, setPreviewVersion] = useState(0)
|
||||
const [detectAllLoading, setDetectAllLoading] = useState(false)
|
||||
const [applyAllLoading, setApplyAllLoading] = useState(false)
|
||||
|
||||
const applyRef = useRef(false)
|
||||
const detectRef = useRef(false)
|
||||
const previewRequestRef = useRef(0)
|
||||
const detectAllRef = useRef(false)
|
||||
const applyAllRef = useRef(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
(
|
||||
record: { id: number; [key: string]: unknown } | null,
|
||||
record: UpstreamUpdateChannel | null,
|
||||
pendingAdd: string[] = [],
|
||||
pendingRemove: string[] = [],
|
||||
tab: 'add' | 'remove' = 'add'
|
||||
) => {
|
||||
if (applyRef.current) return
|
||||
const normAdd = normalizeModelList(pendingAdd)
|
||||
const normRemove = normalizeModelList(pendingRemove)
|
||||
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
|
||||
toast.info(t('No processable upstream model updates for this channel'))
|
||||
return
|
||||
}
|
||||
if (!record?.id) return
|
||||
previewRequestRef.current += 1
|
||||
setPreviewVersion((version) => version + 1)
|
||||
setDetectLoading(false)
|
||||
setDetectError(null)
|
||||
setApplyError(null)
|
||||
setResult(null)
|
||||
setChannel(record)
|
||||
setAddModels(normAdd)
|
||||
setRemoveModels(normRemove)
|
||||
setPreferredTab(tab)
|
||||
setShowModal(true)
|
||||
},
|
||||
[t]
|
||||
[]
|
||||
)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
if (applyRef.current) return
|
||||
previewRequestRef.current += 1
|
||||
setDetectLoading(false)
|
||||
setDetectError(null)
|
||||
setApplyError(null)
|
||||
setResult(null)
|
||||
setShowModal(false)
|
||||
setChannel(null)
|
||||
setAddModels([])
|
||||
@@ -105,55 +119,76 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
addModels?: string[]
|
||||
removeModels?: string[]
|
||||
} = {}) => {
|
||||
if (applyRef.current) return
|
||||
if (!channel?.id) {
|
||||
closeModal()
|
||||
if (
|
||||
applyRef.current ||
|
||||
detectLoading ||
|
||||
detectError ||
|
||||
applyError ||
|
||||
result ||
|
||||
!channel?.id
|
||||
) {
|
||||
return
|
||||
}
|
||||
const normSelectedAdd = normalizeModelList(selectedAdd).filter((model) =>
|
||||
addModels.includes(model)
|
||||
)
|
||||
const normSelectedRemove = normalizeModelList(selectedRemove).filter(
|
||||
(model) => removeModels.includes(model)
|
||||
)
|
||||
if (!normSelectedAdd.length && !normSelectedRemove.length) return
|
||||
applyRef.current = true
|
||||
setApplyLoading(true)
|
||||
try {
|
||||
const normSelectedAdd = normalizeModelList(selectedAdd)
|
||||
const selectedAddSet = new Set(normSelectedAdd)
|
||||
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
|
||||
|
||||
const res = await api.post(
|
||||
const res = await api.post<
|
||||
UpstreamUpdateResponse<{
|
||||
added_models: string[] | null
|
||||
removed_models: string[] | null
|
||||
remaining_models: string[] | null
|
||||
remaining_remove_models: string[] | null
|
||||
}>
|
||||
>(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: channel.id,
|
||||
add_models: normSelectedAdd,
|
||||
ignore_models: ignoreModels,
|
||||
remove_models: normalizeModelList(selectedRemove),
|
||||
ignore_models: [],
|
||||
remove_models: normSelectedRemove,
|
||||
},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, data } = res.data || {}
|
||||
if (!success) {
|
||||
handleServerError(res.data, t('Operation failed'))
|
||||
return
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
throw createServerError(res.data, t('Operation failed'))
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
|
||||
{
|
||||
added: data?.added_models?.length || 0,
|
||||
removed: data?.removed_models?.length || 0,
|
||||
ignored: normalizeModelList(ignoreModels).length,
|
||||
totalIgnored: getManualIgnoredModelCount(data?.settings),
|
||||
}
|
||||
)
|
||||
)
|
||||
closeModal()
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
handleServerError(e, t('Operation failed'))
|
||||
const data = res.data.data
|
||||
setResult({
|
||||
addedModels: normalizeModelList(data.added_models ?? []),
|
||||
removedModels: normalizeModelList(data.removed_models ?? []),
|
||||
remainingModels: normalizeModelList(data.remaining_models ?? []),
|
||||
remainingRemoveModels: normalizeModelList(
|
||||
data.remaining_remove_models ?? []
|
||||
),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
setApplyError(getServerErrorMessage(error, t('Operation failed')))
|
||||
handleServerError(error, t('Operation failed'))
|
||||
} finally {
|
||||
applyRef.current = false
|
||||
setApplyLoading(false)
|
||||
}
|
||||
// A list refresh failure must not turn a successful update into a failed one.
|
||||
void refresh().catch((error: unknown) => handleServerError(error))
|
||||
},
|
||||
[channel, addModels, closeModal, refresh, t]
|
||||
[
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
detectLoading,
|
||||
detectError,
|
||||
applyError,
|
||||
result,
|
||||
refresh,
|
||||
t,
|
||||
]
|
||||
)
|
||||
|
||||
const applyAllUpdates = useCallback(async () => {
|
||||
@@ -193,35 +228,46 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
}, [refresh, t])
|
||||
|
||||
const detectChannelUpdates = useCallback(
|
||||
async (ch: { id: number; [key: string]: unknown } | null) => {
|
||||
if (detectRef.current || !ch?.id) return
|
||||
detectRef.current = true
|
||||
async (ch: UpstreamUpdateChannel | null) => {
|
||||
if (applyRef.current || !ch?.id) return
|
||||
const requestId = ++previewRequestRef.current
|
||||
setChannel(ch)
|
||||
setShowModal(true)
|
||||
setDetectLoading(true)
|
||||
setDetectError(null)
|
||||
setApplyError(null)
|
||||
setResult(null)
|
||||
setAddModels([])
|
||||
setRemoveModels([])
|
||||
try {
|
||||
const res = await api.post(
|
||||
const res = await api.post<
|
||||
UpstreamUpdateResponse<{
|
||||
add_models: string[] | null
|
||||
remove_models: string[] | null
|
||||
}>
|
||||
>(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{ id: ch.id },
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, data } = res.data || {}
|
||||
if (!success) {
|
||||
handleServerError(res.data, t('Detection failed'))
|
||||
return
|
||||
if (requestId !== previewRequestRef.current) return
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
throw createServerError(res.data, t('Detection failed'))
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t('Detection complete: {{add}} to add, {{remove}} to remove', {
|
||||
add: data?.add_models?.length || 0,
|
||||
remove: data?.remove_models?.length || 0,
|
||||
})
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
handleServerError(e, t('Detection failed'))
|
||||
const added = normalizeModelList(res.data.data.add_models ?? [])
|
||||
setAddModels(added)
|
||||
setRemoveModels(normalizeModelList(res.data.data.remove_models ?? []))
|
||||
setPreferredTab(added.length ? 'add' : 'remove')
|
||||
setPreviewVersion((version) => version + 1)
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== previewRequestRef.current) return
|
||||
setDetectError(getServerErrorMessage(error, t('Detection failed')))
|
||||
handleServerError(error, t('Detection failed'))
|
||||
} finally {
|
||||
detectRef.current = false
|
||||
if (requestId === previewRequestRef.current) setDetectLoading(false)
|
||||
}
|
||||
},
|
||||
[refresh, t]
|
||||
[t]
|
||||
)
|
||||
|
||||
const detectAllUpdates = useCallback(async () => {
|
||||
@@ -265,6 +311,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectLoading,
|
||||
detectError,
|
||||
applyError,
|
||||
result,
|
||||
previewVersion,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
@@ -281,6 +332,11 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectLoading,
|
||||
detectError,
|
||||
applyError,
|
||||
result,
|
||||
previewVersion,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
@@ -292,3 +348,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export type ChannelUpstreamUpdateState = ReturnType<
|
||||
typeof useChannelUpstreamUpdates
|
||||
>
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { channelSchema } from '../../types'
|
||||
import {
|
||||
CHANNEL_FORM_DEFAULT_VALUES,
|
||||
channelFormSchema,
|
||||
transformChannelToFormDefaults,
|
||||
transformFormDataToCreatePayload,
|
||||
} from '../channel-form'
|
||||
|
||||
test.each(['invoke_model', 'mantle'] as const)(
|
||||
'AWS creation round-trips the %s interface without losing other settings',
|
||||
(apiType) => {
|
||||
const form = channelFormSchema.parse({
|
||||
...CHANNEL_FORM_DEFAULT_VALUES,
|
||||
name: 'AWS',
|
||||
type: 33,
|
||||
key: 'key|us-east-1',
|
||||
models: 'claude-opus-4-7',
|
||||
aws_api_type: apiType,
|
||||
aws_key_type: 'api_key',
|
||||
settings: '{"custom_option":true}',
|
||||
})
|
||||
const payload = transformFormDataToCreatePayload(form).channel
|
||||
expect(JSON.parse(payload.settings ?? '{}')).toMatchObject({
|
||||
aws_api_type: apiType,
|
||||
aws_key_type: 'api_key',
|
||||
custom_option: true,
|
||||
})
|
||||
const channel = channelSchema.parse({
|
||||
...payload,
|
||||
other: '',
|
||||
remark: '',
|
||||
id: 7,
|
||||
status: 1,
|
||||
created_time: 1,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
})
|
||||
expect(transformChannelToFormDefaults(channel).aws_api_type).toBe(apiType)
|
||||
}
|
||||
)
|
||||
|
||||
test('changing an AWS channel to another provider removes the AWS interface setting', () => {
|
||||
const form = channelFormSchema.parse({
|
||||
...CHANNEL_FORM_DEFAULT_VALUES,
|
||||
name: 'Claude',
|
||||
type: 14,
|
||||
key: 'test-key',
|
||||
models: 'claude-opus-4-7',
|
||||
aws_api_type: 'mantle',
|
||||
settings: '{"aws_api_type":"mantle","aws_key_type":"api_key"}',
|
||||
})
|
||||
const settings = JSON.parse(
|
||||
transformFormDataToCreatePayload(form).channel.settings ?? '{}'
|
||||
)
|
||||
expect(settings).not.toHaveProperty('aws_api_type')
|
||||
expect(settings).not.toHaveProperty('aws_key_type')
|
||||
})
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { TaskPluginOption } from '../../api'
|
||||
import {
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
CHANNEL_TYPE_VLLM,
|
||||
@@ -25,11 +26,19 @@ import {
|
||||
CHANNEL_TYPE_OPTIONS,
|
||||
MODEL_FETCHABLE_TYPES,
|
||||
} from '../../constants'
|
||||
import { channelSchema } from '../../types'
|
||||
import {
|
||||
CHANNEL_FORM_DEFAULT_VALUES,
|
||||
channelFormSchema,
|
||||
transformChannelToFormDefaults,
|
||||
transformFormDataToCreatePayload,
|
||||
transformFormDataToUpdatePayload,
|
||||
} from '../channel-form'
|
||||
import {
|
||||
getChannelPluginExtensions,
|
||||
readTaskExtendPluginKeys,
|
||||
supportsNewAPIUpstream,
|
||||
} from '../channel-plugin-extensions'
|
||||
import { getChannelTypeConfig } from '../channel-type-config'
|
||||
import { getChannelTypeIcon, getKeyPromptForType } from '../channel-utils'
|
||||
|
||||
@@ -45,6 +54,36 @@ function newAPIForm(baseUrl: string) {
|
||||
}
|
||||
|
||||
describe('New API channel', () => {
|
||||
test('preserves cache-read compatibility when editing ordinary channel fields', () => {
|
||||
const channel = channelSchema.parse({
|
||||
id: 22,
|
||||
name: 'Gateway',
|
||||
type: CHANNEL_TYPE_NEW_API,
|
||||
key: '',
|
||||
status: 1,
|
||||
created_time: 1,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
models: 'deepseek-v4.1-flash',
|
||||
group: 'default',
|
||||
settings: JSON.stringify({
|
||||
claude_input_includes_cache_read_models: ['deepseek-v4.1-flash'],
|
||||
allow_speed: true,
|
||||
}),
|
||||
})
|
||||
const form = transformChannelToFormDefaults(channel)
|
||||
const payload = transformFormDataToUpdatePayload(
|
||||
{ ...form, name: 'Renamed gateway' },
|
||||
channel.id
|
||||
)
|
||||
expect(payload.name).toBe('Renamed gateway')
|
||||
expect(JSON.parse(payload.settings ?? '{}')).toMatchObject({
|
||||
claude_input_includes_cache_read_models: ['deepseek-v4.1-flash'],
|
||||
allow_speed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('registers selection, ordering, model discovery, and icon metadata', () => {
|
||||
const option = CHANNEL_TYPE_OPTIONS.find(
|
||||
(item) => item.value === CHANNEL_TYPE_NEW_API
|
||||
@@ -145,3 +184,108 @@ describe.each([
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('New API channel task plugin extensions', () => {
|
||||
const plugins: TaskPluginOption[] = [
|
||||
{
|
||||
key: 'doubao',
|
||||
name: 'Doubao',
|
||||
models: ['seedance'],
|
||||
channelTypes: [45],
|
||||
upstreams: ['vendor', 'new_api'],
|
||||
},
|
||||
{
|
||||
key: 'kling',
|
||||
name: 'Kling',
|
||||
models: ['kling-v2'],
|
||||
upstreams: ['new_api'],
|
||||
},
|
||||
{
|
||||
key: 'acme',
|
||||
name: 'Acme',
|
||||
models: ['acme-video'],
|
||||
upstreams: ['vendor'],
|
||||
},
|
||||
]
|
||||
|
||||
test('only plugins declaring a New API upstream can be bound to a New API channel', () => {
|
||||
expect(plugins.filter(supportsNewAPIUpstream).map((p) => p.key)).toEqual([
|
||||
'doubao',
|
||||
'kling',
|
||||
])
|
||||
expect(supportsNewAPIUpstream({ upstreams: null })).toBe(false)
|
||||
expect(supportsNewAPIUpstream({})).toBe(false)
|
||||
})
|
||||
|
||||
test('reads the extension list together with a single key on New API channels only', () => {
|
||||
expect(
|
||||
readTaskExtendPluginKeys(CHANNEL_TYPE_NEW_API, {
|
||||
task_plugin_key: 'kling',
|
||||
task_extend_plugin_keys: ['doubao', 'kling', '', 'doubao'],
|
||||
})
|
||||
).toEqual(['kling', 'doubao'])
|
||||
expect(readTaskExtendPluginKeys(CHANNEL_TYPE_NEW_API, {})).toEqual([])
|
||||
expect(
|
||||
readTaskExtendPluginKeys(1, { task_extend_plugin_keys: ['doubao'] })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('lists only bound plugins as extensions while other types keep declared channel types', () => {
|
||||
expect(
|
||||
getChannelPluginExtensions(CHANNEL_TYPE_NEW_API, plugins, ['kling'])
|
||||
).toEqual([plugins[1]])
|
||||
expect(getChannelPluginExtensions(CHANNEL_TYPE_NEW_API, plugins)).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(getChannelPluginExtensions(45, plugins, ['kling'])).toEqual([
|
||||
plugins[0],
|
||||
])
|
||||
})
|
||||
|
||||
test('round-trips bound plugins through the channel setting for New API channels only', () => {
|
||||
const channel = channelSchema.parse({
|
||||
id: 7,
|
||||
name: 'Gateway',
|
||||
type: CHANNEL_TYPE_NEW_API,
|
||||
key: '',
|
||||
status: 1,
|
||||
created_time: 1,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
models: 'seedance',
|
||||
group: 'default',
|
||||
base_url: 'https://gateway.example',
|
||||
setting: JSON.stringify({
|
||||
task_plugin_key: 'kling',
|
||||
task_extend_plugin_keys: ['doubao'],
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
transformChannelToFormDefaults(channel).task_extend_plugin_keys
|
||||
).toEqual(['kling', 'doubao'])
|
||||
|
||||
const gateway = transformFormDataToCreatePayload(
|
||||
channelFormSchema.parse({
|
||||
...newAPIForm('https://gateway.example'),
|
||||
task_extend_plugin_keys: ['doubao', 'kling'],
|
||||
})
|
||||
)
|
||||
const gatewaySetting = JSON.parse(gateway.channel.setting ?? '{}')
|
||||
expect(gatewaySetting).toMatchObject({
|
||||
task_extend_plugin_keys: ['doubao', 'kling'],
|
||||
})
|
||||
expect(gatewaySetting).not.toHaveProperty('task_plugin_key')
|
||||
|
||||
const openai = transformFormDataToCreatePayload(
|
||||
channelFormSchema.parse({
|
||||
...newAPIForm('https://gateway.example'),
|
||||
type: 1,
|
||||
task_extend_plugin_keys: ['doubao'],
|
||||
})
|
||||
)
|
||||
expect(JSON.parse(openai.channel.setting ?? '{}')).not.toHaveProperty(
|
||||
'task_extend_plugin_keys'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import type { Channel } from '../../types'
|
||||
import {
|
||||
applyPriorityPreview,
|
||||
previewPriorityDrop,
|
||||
} from '../channel-priority-order'
|
||||
|
||||
const channels = [
|
||||
{ id: 4, priority: 100 },
|
||||
{ id: 3, priority: 80 },
|
||||
{ id: 2, priority: 80 },
|
||||
{ id: 1, priority: 0 },
|
||||
] as Channel[]
|
||||
|
||||
test('before and after target the entire equal-priority tier', () => {
|
||||
expect(
|
||||
previewPriorityDrop(
|
||||
channels,
|
||||
{ id: 1, target_id: 2, position: 'before' },
|
||||
true
|
||||
).map((c) => c.id)
|
||||
).toEqual([4, 1, 3, 2])
|
||||
expect(
|
||||
previewPriorityDrop(
|
||||
channels,
|
||||
{ id: 4, target_id: 3, position: 'after' },
|
||||
true
|
||||
).map((c) => c.id)
|
||||
).toEqual([3, 2, 4, 1])
|
||||
expect(channels.map((c) => c.id)).toEqual([4, 3, 2, 1])
|
||||
})
|
||||
|
||||
test('ascending view reverses tier boundaries but preserves the ID tie break', () => {
|
||||
const ascending = [...channels].sort(
|
||||
(a, b) => (a.priority ?? 0) - (b.priority ?? 0) || b.id - a.id
|
||||
)
|
||||
expect(
|
||||
previewPriorityDrop(
|
||||
ascending,
|
||||
{ id: 4, target_id: 3, position: 'after' },
|
||||
false
|
||||
).map((c) => c.id)
|
||||
).toEqual([1, 4, 3, 2])
|
||||
expect(
|
||||
previewPriorityDrop(
|
||||
channels,
|
||||
{ id: 4, target_id: 3, position: 'same' },
|
||||
true
|
||||
).map((c) => c.id)
|
||||
).toEqual([4, 3, 2, 1])
|
||||
})
|
||||
|
||||
test('server preview applies visible changes and keeps deterministic null-zero ordering', () => {
|
||||
const preview = applyPriorityPreview(
|
||||
channels,
|
||||
{
|
||||
revision: 'next',
|
||||
changes: [],
|
||||
items: [
|
||||
{ id: 4, priority: null },
|
||||
{ id: 3, priority: 80 },
|
||||
{ id: 2, priority: 80 },
|
||||
{ id: 1, priority: 90 },
|
||||
],
|
||||
},
|
||||
true
|
||||
)
|
||||
expect(preview.map((c) => [c.id, c.priority])).toEqual([
|
||||
[1, 90],
|
||||
[3, 80],
|
||||
[2, 80],
|
||||
[4, 0],
|
||||
])
|
||||
expect(channels[0].priority).toBe(100)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { channelSchema } from '../../types'
|
||||
import {
|
||||
CHANNEL_FORM_DEFAULT_VALUES,
|
||||
channelFormSchema,
|
||||
transformChannelToFormDefaults,
|
||||
transformFormDataToCreatePayload,
|
||||
type ChannelFormValues,
|
||||
} from '../channel-form'
|
||||
|
||||
const CHANNEL_TYPE_VERTEX_AI = 41
|
||||
|
||||
function vertexAPIKeyForm(
|
||||
overrides: Partial<ChannelFormValues> = {}
|
||||
): ChannelFormValues {
|
||||
return {
|
||||
...CHANNEL_FORM_DEFAULT_VALUES,
|
||||
name: 'Vertex AI',
|
||||
type: CHANNEL_TYPE_VERTEX_AI,
|
||||
key: 'test-key',
|
||||
models: 'gemini-4-argon',
|
||||
other: '{"default":"global"}',
|
||||
vertex_key_type: 'api_key',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function savedSettings(form: ChannelFormValues): Record<string, unknown> {
|
||||
const payload = transformFormDataToCreatePayload(
|
||||
channelFormSchema.parse(form)
|
||||
)
|
||||
return JSON.parse(payload.channel.settings ?? '{}')
|
||||
}
|
||||
|
||||
describe('Vertex AI API Key project ID', () => {
|
||||
test('saves the trimmed project ID in API Key mode', () => {
|
||||
const settings = savedSettings(
|
||||
vertexAPIKeyForm({ vertex_project_id: ' my-project ' })
|
||||
)
|
||||
|
||||
expect(settings).toMatchObject({
|
||||
vertex_key_type: 'api_key',
|
||||
vertex_project_id: 'my-project',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts API Key mode without a project ID and saves none', () => {
|
||||
const form = vertexAPIKeyForm({ vertex_project_id: ' ' })
|
||||
|
||||
expect(channelFormSchema.safeParse(form).success).toBe(true)
|
||||
expect(savedSettings(form)).not.toHaveProperty('vertex_project_id')
|
||||
})
|
||||
|
||||
test('drops a saved project ID after switching to JSON keys', () => {
|
||||
const settings = savedSettings(
|
||||
vertexAPIKeyForm({
|
||||
vertex_key_type: 'json',
|
||||
key: '{"project_id":"json-project"}',
|
||||
vertex_project_id: 'my-project',
|
||||
settings: JSON.stringify({
|
||||
vertex_key_type: 'api_key',
|
||||
vertex_project_id: 'my-project',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(settings).toMatchObject({ vertex_key_type: 'json' })
|
||||
expect(settings).not.toHaveProperty('vertex_project_id')
|
||||
})
|
||||
|
||||
test('loads the saved project ID when editing a channel', () => {
|
||||
const channel = channelSchema.parse({
|
||||
id: 7,
|
||||
name: 'Vertex AI',
|
||||
type: CHANNEL_TYPE_VERTEX_AI,
|
||||
key: '',
|
||||
status: 1,
|
||||
created_time: 1,
|
||||
test_time: 0,
|
||||
response_time: 0,
|
||||
balance_updated_time: 0,
|
||||
models: 'gemini-4-argon',
|
||||
group: 'default',
|
||||
other: '{"default":"global"}',
|
||||
settings: JSON.stringify({
|
||||
vertex_key_type: 'api_key',
|
||||
vertex_project_id: 'my-project',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(transformChannelToFormDefaults(channel).vertex_project_id).toBe(
|
||||
'my-project'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,11 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||
label: 'Anthropic Messages to OpenAI Chat',
|
||||
triggerLabel: 'To OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'claude_messages_to_openai_responses',
|
||||
label: 'Anthropic Messages to OpenAI Responses',
|
||||
triggerLabel: 'To OpenAI Responses',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_anthropic_messages',
|
||||
label: 'OpenAI Chat to Anthropic Messages',
|
||||
@@ -71,6 +76,11 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||
label: 'Gemini Generate Content to OpenAI Chat',
|
||||
triggerLabel: 'To OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'gemini_generate_content_to_openai_responses',
|
||||
label: 'Gemini Generate Content to OpenAI Responses',
|
||||
triggerLabel: 'To OpenAI Responses',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_gemini_generate_content',
|
||||
label: 'OpenAI Chat to Gemini Generate Content',
|
||||
@@ -449,7 +459,11 @@ export function getAdvancedCustomConverterDefaults(
|
||||
) {
|
||||
return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() }
|
||||
}
|
||||
if (converter === 'openai_chat_completions_to_openai_responses') {
|
||||
if (
|
||||
converter === 'openai_chat_completions_to_openai_responses' ||
|
||||
converter === 'claude_messages_to_openai_responses' ||
|
||||
converter === 'gemini_generate_content_to_openai_responses'
|
||||
) {
|
||||
return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() }
|
||||
}
|
||||
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
||||
@@ -924,7 +938,10 @@ function isConverterPathAllowed(
|
||||
): boolean {
|
||||
if (converter === 'none') return true
|
||||
if (incomingPath === '/v1/alpha/search') return false
|
||||
if (converter === 'anthropic_messages_to_openai_chat_completions') {
|
||||
if (
|
||||
converter === 'anthropic_messages_to_openai_chat_completions' ||
|
||||
converter === 'claude_messages_to_openai_responses'
|
||||
) {
|
||||
return incomingPath === '/v1/messages'
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -21,6 +21,7 @@ import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { getServerMessage } from '@/lib/server-error-message'
|
||||
|
||||
import {
|
||||
copyChannel,
|
||||
@@ -316,7 +317,8 @@ export async function handleTestChannel(
|
||||
}
|
||||
onTestComplete?.(true, responseTime)
|
||||
} else {
|
||||
const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
const errorMsg =
|
||||
getServerMessage(response) || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
if (!options?.silent) {
|
||||
handleServerError(response, undefined, {
|
||||
title: i18next.t('{{target}} test failed', { target }),
|
||||
@@ -328,9 +330,10 @@ export async function handleTestChannel(
|
||||
onTestComplete?.(false, responseTime, errorMsg, response.error_code)
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
const err = _error as { response?: { data?: { message?: string } } }
|
||||
const err = _error as { response?: { data?: unknown } }
|
||||
const errorMsg =
|
||||
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
getServerMessage(err?.response?.data) ||
|
||||
i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
const target = getChannelTestLabel(options)
|
||||
if (!options?.silent) {
|
||||
handleServerError(_error, undefined, {
|
||||
|
||||
@@ -85,6 +85,7 @@ const CONFIGURATION_BLOCKS = {
|
||||
'proxy',
|
||||
'http_protocol',
|
||||
'http2_connection_shards',
|
||||
'tls_insecure_skip_verify',
|
||||
'disable_task_polling_sleep',
|
||||
],
|
||||
},
|
||||
@@ -176,6 +177,7 @@ export function getChannelConfigurationState(
|
||||
values.proxy?.trim() ||
|
||||
(values.http_protocol && values.http_protocol !== 'auto') ||
|
||||
(values.http2_connection_shards ?? 1) > 1 ||
|
||||
values.tls_insecure_skip_verify ||
|
||||
values.disable_task_polling_sleep
|
||||
),
|
||||
upstreamModelDetection:
|
||||
|
||||
@@ -42,6 +42,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
|
||||
'proxy',
|
||||
'http_protocol',
|
||||
'http2_connection_shards',
|
||||
'tls_insecure_skip_verify',
|
||||
'system_prompt',
|
||||
'system_prompt_override',
|
||||
'allow_service_tier',
|
||||
|
||||
Loaded 100 of 1022 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user