From a27f223fc06a2523b13af00eea6c106c1146f30a Mon Sep 17 00:00:00 2001 From: liuhy Date: Sat, 25 Jul 2026 08:27:18 -0700 Subject: [PATCH] [Studio] Load ops settings after mount --- web/src/pages/studio/Ops.tsx | 37 +++++--- web/src/pages/studio/__tests__/Ops.test.tsx | 94 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 web/src/pages/studio/__tests__/Ops.test.tsx diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx index bbda058c..4a547cc9 100644 --- a/web/src/pages/studio/Ops.tsx +++ b/web/src/pages/studio/Ops.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { App, Button, Input, Select, Space, Switch, Typography } from 'antd'; import { FloppyDisk, Plus } from '@phosphor-icons/react'; import { useLang } from '../../i18n/LangContext'; @@ -30,6 +30,7 @@ import { const OpsPage: React.FC = () => { const { t } = useLang(); const { message } = App.useApp(); + const fetchFailedMessage = t('ops.fetchFailed'); const [namesrvAddrList, setNamesrvAddrList] = useState([]); const [selectedNamesrv, setSelectedNamesrv] = useState(''); @@ -38,26 +39,36 @@ const OpsPage: React.FC = () => { const [useTLS, setUseTLS] = useState(false); const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); - // One-time initialization (ESLint-compliant: no useEffect+setState) - const initialized = useRef(null); - if (initialized.current == null) { - initialized.current = true; + useEffect(() => { + let cancelled = false; + const loadOpsData = async () => { try { const userRole = sessionStorage.getItem('userrole'); - setWriteOperationEnabled(userRole === null || userRole === '1'); + if (!cancelled) { + setWriteOperationEnabled(userRole === null || userRole === '1'); + } const data = await queryOpsHomePage(); - setNamesrvAddrList(data.namesvrAddrList); - setUseVIPChannel(data.useVIPChannel); - setUseTLS(data.useTLS); - setSelectedNamesrv(data.currentNamesrv); + if (!cancelled) { + setNamesrvAddrList(data.namesvrAddrList); + setUseVIPChannel(data.useVIPChannel); + setUseTLS(data.useTLS); + setSelectedNamesrv(data.currentNamesrv); + } } catch { - message.error(t('ops.fetchFailed')); + if (!cancelled) { + message.error(fetchFailedMessage); + } } }; - loadOpsData(); - } + + void loadOpsData(); + + return () => { + cancelled = true; + }; + }, [fetchFailedMessage, message]); const handleUpdateNameSvrAddr = async () => { if (!selectedNamesrv) { diff --git a/web/src/pages/studio/__tests__/Ops.test.tsx b/web/src/pages/studio/__tests__/Ops.test.tsx new file mode 100644 index 00000000..fe7aa034 --- /dev/null +++ b/web/src/pages/studio/__tests__/Ops.test.tsx @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReactElement } from 'react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { App } from 'antd'; +import { LangProvider } from '../../../i18n/LangContext'; +import OpsPage from '../Ops'; +import { queryOpsHomePage } from '../../../api/ops'; + +vi.mock('../../../api/ops', () => ({ + addNameSvrAddr: vi.fn(), + queryOpsHomePage: vi.fn(), + updateIsVIPChannel: vi.fn(), + updateNameSvrAddr: vi.fn(), + updateUseTLS: vi.fn(), +})); + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +const renderWithProviders = (ui: ReactElement) => { + return render( + + {ui} + , + ); +}; + +describe('OpsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + vi.mocked(queryOpsHomePage).mockResolvedValue({ + namesvrAddrList: ['127.0.0.1:9876', '127.0.0.2:9876'], + useVIPChannel: true, + useTLS: false, + currentNamesrv: '127.0.0.1:9876', + }); + }); + + it('loads NameServer and channel settings after mount', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(queryOpsHomePage).toHaveBeenCalledTimes(1); + }); + + expect(await screen.findByText('127.0.0.1:9876')).toBeInTheDocument(); + expect(screen.getAllByRole('switch')[0]).toBeChecked(); + expect(screen.getAllByRole('switch')[1]).not.toBeChecked(); + }); + + it('hides write controls for read-only users', async () => { + sessionStorage.setItem('userrole', '2'); + + renderWithProviders(); + + await waitFor(() => { + expect(queryOpsHomePage).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByPlaceholderText('NamesrvAddr')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /新增|添加/ })).not.toBeInTheDocument(); + }); +});