From e22f3f92cebf46020f11ee0c4c153b798e64dd62 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 15 Oct 2026 02:54:26 +0800 Subject: [PATCH 1/3] fix(web): isolate tag drafts and clarify callback boundaries --- .../risk-acknowledgement-dialog.tsx | 12 +- .../dialogs/fetch-models-dialog.tsx | 2 +- .../dialogs/multi-key-manage-dialog.tsx | 4 +- .../tag-batch-edit-dialog.runtime.test.tsx | 135 +++++++ .../dialogs/tag-batch-edit-dialog.tsx | 100 +++--- .../components/users/user-charts.tsx | 1 - .../components/tabs/account-bindings-tab.tsx | 4 +- .../channel-health/index.runtime.test.tsx | 83 +++++ .../smart-ops/channel-health/index.tsx | 2 +- .../system-settings/auth/passkey-section.tsx | 6 +- .../components/settings-page-context.tsx | 7 +- .../settings-submit.runtime.test.tsx | 328 ++++++++++++++++++ .../content/chat-settings-section.tsx | 7 +- .../content/json-toggle-section.tsx | 7 +- .../models/claude-settings-card.tsx | 7 +- 15 files changed, 638 insertions(+), 67 deletions(-) create mode 100644 web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx create mode 100644 web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx create mode 100644 web/default/src/features/system-settings/components/settings-submit.runtime.test.tsx diff --git a/web/default/src/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx index 03eaa846..1af96d34 100644 --- a/web/default/src/components/risk-acknowledgement-dialog.tsx +++ b/web/default/src/components/risk-acknowledgement-dialog.tsx @@ -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( diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx index cc6e883a..6d24ae6b 100644 --- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -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) diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index 5e34f458..cbb43e25 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -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) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx new file mode 100644 index 00000000..15cc0069 --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx @@ -0,0 +1,135 @@ +/* +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 . + +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 void>() + 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((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 ( + <> + + + + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + 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 + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index a8dc78cc..8d69e2e6 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . 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' @@ -52,28 +52,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 ( + + ) +} + +function TagBatchEditDialogContent({ open, onOpenChange, -}: TagBatchEditDialogProps) { + currentTag, + groupsData, + isLoadingGroups, +}: TagBatchEditDialogProps & { + currentTag: string | null + groupsData: Awaited> | 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([]) - // 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 [] @@ -84,40 +101,37 @@ 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) + } + await getAllModels() + } 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 diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 137a58e6..692c853a 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -148,7 +148,6 @@ export function UserCharts() { t, topUserLimit, customization.preset, - customization.radius, ] ) diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index d806b4a1..08b31bcc 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -117,7 +117,9 @@ export function AccountBindingsTab({ const handleBindCustomOAuth = (provider: { id: number; name: string }) => { const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` - window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + window.location.assign( + `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + ) } useEffect(() => { diff --git a/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx new file mode 100644 index 00000000..fcc1200b --- /dev/null +++ b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx @@ -0,0 +1,83 @@ +/* +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 . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const errorKey = 'We could not load the channel health policy.' +const frenchError = 'Impossible de charger la politique des canaux.' +const testEnv = createReactTestEnvironment({ + resources: { + en: { translation: { [errorKey]: errorKey } }, + fr: { translation: { [errorKey]: frenchError } }, + }, +}) +await testEnv.setup() +const { ChannelHealth } = await import('./index') +after(() => testEnv.teardown()) + +test('policy errors translate without changing the data cache or repeating the policy request', async () => { + const originalAdapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + let policyRequests = 0 + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + const policy = config.url === '/api/smart-ops/channel-health/policy' + if (policy) policyRequests += 1 + return { + data: { success: true, data: [] }, + config, + status: 200, + statusText: 'OK', + headers: {}, + } + } + const queryKey = ['smart-ops', 'channel-health-policy'] + const view = await testEnv.render( + + + + ) + try { + await waitFor(() => { + assert.equal(client.getQueryState(queryKey)?.status, 'error') + assert.ok(view.container.textContent?.includes(errorKey)) + }) + const cachedError = client.getQueryState(queryKey)?.error + await act(async () => { + await testEnv.i18n.changeLanguage('fr') + }) + await waitFor(() => { + assert.ok(view.container.textContent?.includes(frenchError)) + }) + assert.equal(client.getQueryState(queryKey)?.error, cachedError) + assert.equal(policyRequests, 1) + assert.equal(client.getQueryCache().findAll({ queryKey }).length, 1) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/smart-ops/channel-health/index.tsx b/web/default/src/features/smart-ops/channel-health/index.tsx index 8cab67fe..aad66e57 100644 --- a/web/default/src/features/smart-ops/channel-health/index.tsx +++ b/web/default/src/features/smart-ops/channel-health/index.tsx @@ -397,7 +397,7 @@ export function ChannelHealth(): ReactElement { ) if (!hasAllOptions) { throw new Error( - response.message || t('We could not load the channel health policy.') + response.message || 'We could not load the channel health policy.' ) } return response diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx index fdd54ccf..63b88c53 100644 --- a/web/default/src/features/system-settings/auth/passkey-section.tsx +++ b/web/default/src/features/system-settings/auth/passkey-section.tsx @@ -187,9 +187,11 @@ export function PasskeySection(props: PasskeySectionProps) { return (
- + void form.handleSubmit(onSubmit)(event)} + > void form.handleSubmit(onSubmit)()} isSaving={updateOption.isPending} /> } -export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { +export function SettingsPageFormActions({ + saveButtonRef, + ...props +}: SettingsPageFormActionsProps) { const { t } = useTranslation() const saveLabel = props.isSaving ? (props.savingLabel ?? 'Saving...') @@ -116,7 +119,7 @@ export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { )} + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + try { + await view.click( + within(view.container).getByRole('button', { name: 'Select ready tag' }) + ) + await act(async () => { + await new Promise((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 + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index 8d69e2e6..50499588 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -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' @@ -118,7 +113,6 @@ function TagBatchEditDialogContent({ if (tagModelsResponse.success && tagModelsResponse.data) { setModels(tagModelsResponse.data) } - await getAllModels() } catch (error: unknown) { if (!cancelled) { reportLoadError(error)