Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions web/default/src/components/risk-acknowledgement-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,16 @@ export function RiskAcknowledgementDialog({
NormalizedRequiredTextPart[]
>(() => {
let inputIndex = 0
return requiredTextParts.map((part) => {
const parts: NormalizedRequiredTextPart[] = []
for (const part of requiredTextParts) {
if (part.type === 'input') {
const normalizedPart = { ...part, inputIndex }
parts.push({ ...part, inputIndex })
inputIndex += 1
return normalizedPart
} else {
parts.push(part)
}
return part
})
}
return parts
}, [requiredTextParts])

const requiredTextInputCount = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function FetchModelsDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeChannel?.id, customFetcher])

const handleFetchModels = async () => {
async function handleFetchModels() {
if (!activeChannel && !customFetcher) return

setIsFetching(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ export function MultiKeyManageDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])

const loadKeyStatus = async (
async function loadKeyStatus(
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
) => {
) {
if (!currentRow) return

setIsLoading(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
Copyright (C) 2023-2026 MAX-API-Next

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 https://github.com/MAX-API-Next/MAX-API/issues
*/
import { act, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createReactTestEnvironment } from '@/test/react'
import { waitFor, within } from '@testing-library/react'
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import { api } from '@/lib/api'

const testEnv = createReactTestEnvironment()
await testEnv.setup()
const previousStorage = Object.getOwnPropertyDescriptor(
globalThis,
'localStorage'
)
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: testEnv.dom.window.localStorage,
})

const { ChannelsProvider, useChannels } = await import('../channels-provider')
const { TagBatchEditDialog } = await import('./tag-batch-edit-dialog')
after(() => {
testEnv.teardown()
if (previousStorage)
Object.defineProperty(globalThis, 'localStorage', previousStorage)
else Reflect.deleteProperty(globalThis, 'localStorage')
})

test('a late response for a previous tag cannot overwrite the current tag', async () => {
const originalAdapter = api.defaults.adapter
const pending = new Map<string, (models: string) => void>()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const requests: string[] = []
let groupRequests = 0
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
api.defaults.adapter = async (config) => {
assert.equal(config.method, 'get')
if (config.url === '/api/group/') groupRequests += 1
let data: unknown = { success: true, data: [] }
if (config.url === '/api/channel/tag/models') {
const tag = String(config.params.tag)
requests.push(tag)
const models = await new Promise<string>((resolve) => {
pending.set(tag, resolve)
})
data = { success: true, data: models }
}
return { data, config, status: 200, statusText: 'OK', headers: {} }
}
function Harness() {
const { setCurrentTag } = useChannels()
const [, setRevision] = useState(0)
return (
<>
<button onClick={() => setCurrentTag('first')}>Select first tag</button>
<button onClick={() => setCurrentTag('second')}>
Select second tag
</button>
<button onClick={() => setRevision((value) => value + 1)}>
Rerender
</button>
<TagBatchEditDialog open onOpenChange={() => {}} />
</>
)
}
const view = await testEnv.render(
<QueryClientProvider client={client}>
<ChannelsProvider>
<Harness />
</ChannelsProvider>
</QueryClientProvider>
)
const first = within(view.container).getByRole('button', {
name: 'Select first tag',
})
const second = within(view.container).getByRole('button', {
name: 'Select second tag',
})
const rerender = within(view.container).getByRole('button', {
name: 'Rerender',
})
const fields = within(testEnv.dom.window.document.body)
try {
await view.click(first)
await waitFor(() => assert.ok(pending.has('first')))
await view.click(second)
await waitFor(() => assert.ok(pending.has('second')))
await act(async () => pending.get('second')!('model-second'))
await waitFor(() => {
assert.equal(
(fields.getByLabelText('Models') as HTMLTextAreaElement).value,
'model-second'
)
})
await act(async () => pending.get('first')!('model-first'))
await waitFor(() => {
assert.equal(
(fields.getByLabelText('Models') as HTMLTextAreaElement).value,
'model-second'
)
assert.equal(
(fields.getByLabelText('Tag Name') as HTMLInputElement).value,
'second'
)
})
await view.click(rerender)
await act(async () => {
await testEnv.i18n.changeLanguage('fr')
})
assert.deepEqual(requests, ['first', 'second'])
assert.equal(groupRequests, 1)
} finally {
await view.unmount()
client.clear()
api.defaults.adapter = originalAdapter
}
})

test('tag models become editable without waiting for an unused all-models request', async () => {
const originalAdapter = api.defaults.adapter
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
let auxiliaryRequests = 0
let releaseAuxiliary: () => void = () => {}
api.defaults.adapter = async (config) => {
assert.equal(config.method, 'get')
let data: unknown = { success: true, data: [] }
if (config.url === '/api/channel/tag/models') {
data = { success: true, data: 'tag-only-model' }
} else if (config.url === '/api/channel/models') {
auxiliaryRequests += 1
data = await new Promise((resolve) => {
releaseAuxiliary = () => resolve({ success: true, data: [] })
})
}
return { data, config, status: 200, statusText: 'OK', headers: {} }
}
function Harness() {
const { setCurrentTag } = useChannels()
return (
<>
<button onClick={() => setCurrentTag('ready')}>Select ready tag</button>
<TagBatchEditDialog open onOpenChange={() => {}} />
</>
)
}
const view = await testEnv.render(
<QueryClientProvider client={client}>
<ChannelsProvider>
<Harness />
</ChannelsProvider>
</QueryClientProvider>
)
try {
await view.click(
within(view.container).getByRole('button', { name: 'Select ready tag' })
)
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
})
const models = within(testEnv.dom.window.document.body).queryByLabelText(
'Models'
) as HTMLTextAreaElement | null
assert.equal(models !== null, true)
assert.equal(models?.value, 'tag-only-model')
assert.equal(auxiliaryRequests, 0)
} finally {
await act(async () => releaseAuxiliary())
await view.unmount()
client.clear()
api.defaults.adapter = originalAdapter
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useEffectEvent, useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
Expand All @@ -36,12 +36,7 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
editTagChannels,
getAllModels,
getGroups,
} from '../../api'
import { getTagModels, editTagChannels, getGroups } from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
Expand All @@ -52,28 +47,45 @@ type TagBatchEditDialogProps = {
onOpenChange: (open: boolean) => void
}

export function TagBatchEditDialog({
export function TagBatchEditDialog(props: TagBatchEditDialogProps) {
const { currentTag } = useChannels()
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
return (
<TagBatchEditDialogContent
key={props.open ? `open:${currentTag}` : 'closed'}
{...props}
currentTag={currentTag}
groupsData={groupsData}
isLoadingGroups={isLoadingGroups}
/>
)
}

function TagBatchEditDialogContent({
open,
onOpenChange,
}: TagBatchEditDialogProps) {
currentTag,
groupsData,
isLoadingGroups,
}: TagBatchEditDialogProps & {
currentTag: string | null
groupsData: Awaited<ReturnType<typeof getGroups>> | undefined
isLoadingGroups: boolean
}) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isLoading, setIsLoading] = useState(Boolean(open && currentTag))
const [isSaving, setIsSaving] = useState(false)

// Form fields
const [newTag, setNewTag] = useState('')
const [newTag, setNewTag] = useState(currentTag ?? '')
const [models, setModels] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [groups, setGroups] = useState<string[]>([])

// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})

// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
Expand All @@ -84,40 +96,36 @@ export function TagBatchEditDialog({
}))
}, [groupsData, groups])

useEffect(() => {
if (open && currentTag) {
loadTagData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentTag])

const loadTagData = async () => {
if (!currentTag) return

setIsLoading(true)
try {
// Fetch current tag models
const tagModelsResponse = await getTagModels(currentTag)
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
const reportLoadError = useEffectEvent((error: unknown) => {
toast.error(
error instanceof Error ? error.message : t('Failed to load tag data')
)
})

// Fetch all available models (for future use if needed)
const allModelsResponse = await getAllModels()
if (allModelsResponse.success && allModelsResponse.data) {
// Available models could be used for autocomplete in the future
useEffect(() => {
if (!open || !currentTag) return
const tag = currentTag
let cancelled = false
async function loadTagData() {
try {
const tagModelsResponse = await getTagModels(tag)
if (cancelled) return
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
} catch (error: unknown) {
if (!cancelled) {
reportLoadError(error)
}
} finally {
if (!cancelled) setIsLoading(false)
}

// Initialize new tag with current tag name
setNewTag(currentTag)
} catch (_error: unknown) {
toast.error(
_error instanceof Error ? _error.message : t('Failed to load tag data')
)
} finally {
setIsLoading(false)
}
}
void loadTagData()
return () => {
cancelled = true
}
}, [open, currentTag])

const handleSave = async () => {
if (!currentTag) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ export function UserCharts() {
t,
topUserLimit,
customization.preset,
customization.radius,
]
)

Expand Down
Loading
Loading