This is an automated email from the ASF dual-hosted git repository. young pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push: new 53f50dab3 test(routes): crud (#3070) 53f50dab3 is described below commit 53f50dab3c5ab94ca9890d5f7cf61bcf6b94ae7b Author: YYYoung <isk...@outlook.com> AuthorDate: Wed May 21 12:52:56 2025 +0800 test(routes): crud (#3070) --- e2e/pom/routes.ts | 2 + .../FormPartUpstream/util.ts => e2e/pom/type.ts | 26 +- e2e/pom/upstreams.ts | 2 + e2e/tests/routes.crud-all-fields.spec.ts | 380 +++++++++++++++ e2e/tests/routes.crud-required-fields.spec.ts | 182 ++++++++ e2e/tests/upstreams.crud-all-fields.spec.ts | 443 +----------------- e2e/tests/upstreams.crud-required-fields.spec.ts | 65 +-- e2e/utils/test.ts | 1 + e2e/utils/{ui.ts => ui/index.ts} | 21 +- e2e/utils/ui/upstreams.ts | 517 +++++++++++++++++++++ .../form-slice/FormItemPlugins/PluginCard.tsx | 14 +- .../FormItemPlugins/PluginEditorDrawer.tsx | 6 +- .../form-slice/FormItemPlugins/index.tsx | 4 +- src/components/form-slice/FormPartBasic.tsx | 40 +- src/components/form-slice/FormPartUpstream/util.ts | 12 +- src/components/form/Editor.tsx | 126 +++-- src/components/form/Select.tsx | 7 +- src/locales/en/common.json | 7 + src/routes/routes/detail.$id.tsx | 5 +- src/styles/global.css | 4 + vite.config.ts | 7 + 21 files changed, 1304 insertions(+), 567 deletions(-) diff --git a/e2e/pom/routes.ts b/e2e/pom/routes.ts index 96caf142f..06bf7c69c 100644 --- a/e2e/pom/routes.ts +++ b/e2e/pom/routes.ts @@ -22,6 +22,8 @@ const locator = { page.getByRole('link', { name: 'Routes', exact: true }), getAddRouteBtn: (page: Page) => page.getByRole('button', { name: 'Add Route', exact: true }), + getAddBtn: (page: Page) => + page.getByRole('button', { name: 'Add', exact: true }), }; const assert = { diff --git a/src/components/form-slice/FormPartUpstream/util.ts b/e2e/pom/type.ts similarity index 63% copy from src/components/form-slice/FormPartUpstream/util.ts copy to e2e/pom/type.ts index cf49dd738..2768c917a 100644 --- a/src/components/form-slice/FormPartUpstream/util.ts +++ b/e2e/pom/type.ts @@ -14,16 +14,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { produce } from 'immer'; -import { isNotEmpty } from 'rambdax'; -import type { APISIXType } from '@/types/schema/apisix'; +import { type Locator, type Page } from '@playwright/test'; -import type { FormPartUpstreamType } from './schema'; +export type POMLocator = { + getAddBtn: (page: Page) => Locator; +}; -export const produceToUpstreamForm = (upstream: APISIXType['Upstream']) => - produce(upstream, (d: FormPartUpstreamType) => { - d.__checksEnabled = !!d.checks && isNotEmpty(d.checks); - d.__checksPassiveEnabled = - !!d.checks?.passive && isNotEmpty(d.checks.passive); - }); +export type POMAssert = { + isIndexPage: (page: Page) => Promise<void>; + isAddPage: (page: Page) => Promise<void>; + isDetailPage: (page: Page) => Promise<void>; +}; + +export type POMGoto = { + toIndex: (page: Page) => void; + toAdd: (page: Page) => void; +}; + +export type CommonPOM = POMLocator & POMAssert & POMGoto; diff --git a/e2e/pom/upstreams.ts b/e2e/pom/upstreams.ts index 2f27e7bb0..32f8e7f88 100644 --- a/e2e/pom/upstreams.ts +++ b/e2e/pom/upstreams.ts @@ -22,6 +22,8 @@ const locator = { page.getByRole('link', { name: 'Upstreams' }), getAddUpstreamBtn: (page: Page) => page.getByRole('button', { name: 'Add Upstream' }), + getAddBtn: (page: Page) => + page.getByRole('button', { name: 'Add', exact: true }), }; const assert = { diff --git a/e2e/tests/routes.crud-all-fields.spec.ts b/e2e/tests/routes.crud-all-fields.spec.ts new file mode 100644 index 000000000..9487f7517 --- /dev/null +++ b/e2e/tests/routes.crud-all-fields.spec.ts @@ -0,0 +1,380 @@ +/** + * 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 { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamAllFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const routeNameWithAllFields = randomId('test-route-full'); +const routeUri = '/test-route-all-fields'; +const description = 'This is a test description for the route with all fields'; +// Define nodes to be used in the upstream section +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'test.com', port: 80, weight: 100 }, + { host: 'test2.com', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('should CRUD route with all fields', async ({ page }) => { + test.setTimeout(30000); + + // Navigate to the route list page + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + // Click the add route button + await routesPom.getAddRouteBtn(page).click(); + await routesPom.isAddPage(page); + + await test.step('fill in all fields', async () => { + // Fill in basic fields + await page + .getByLabel('Name', { exact: true }) + .first() + .fill(routeNameWithAllFields); + await page.getByLabel('Description').first().fill(description); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + + // Select HTTP methods + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + await page.getByRole('option', { name: 'POST' }).click(); + await page.getByRole('option', { name: 'PUT' }).click(); + await page.getByRole('option', { name: 'DELETE' }).click(); + await page.keyboard.press('Escape'); + + // Fill in Host field - using more specific selector + await page.getByLabel('Host', { exact: true }).first().fill('example.com'); + + // Fill in Remote Address field - using more specific selector + await page + .getByLabel('Remote Address', { exact: true }) + .first() + .fill('192.168.1.0/24'); + + // Set Priority + await page.getByLabel('Priority', { exact: true }).first().fill('100'); + + // Toggle Status + const status = page.getByRole('textbox', { name: 'Status', exact: true }); + await status.click(); + // Ensure it's checked after the click + await page.getByRole('option', { name: 'Disabled' }).click(); + await expect(status).toHaveValue('Disabled'); + + // Add upstream nodes + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamAllFields( + test, + upstreamSection, + { + nodes: nodes, + name: randomId('test-upstream-full'), + desc: 'test', + }, + page + ); + + // Add plugins + const selectPluginsBtn = page.getByRole('button', { + name: 'Select Plugins', + }); + await selectPluginsBtn.click(); + + // Add basic-auth plugin + const selectPluginsDialog = page.getByRole('dialog', { + name: 'Select Plugins', + }); + const searchInput = selectPluginsDialog.getByPlaceholder('Search'); + await searchInput.fill('basic-auth'); + + await selectPluginsDialog + .getByTestId('plugin-basic-auth') + .getByRole('button', { name: 'Add' }) + .click(); + + const clearEditor = async () => { + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).monaco.editor.getEditors()[0]?.setValue(''); + }); + }; + + const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' }); + const editorLoading = addPluginDialog.getByTestId('editor-loading'); + await expect(editorLoading).toBeHidden(); + const editor = addPluginDialog.getByRole('code').getByRole('textbox'); + await clearEditor(); + await editor.fill('{"hide_credentials": true}'); + // add plugin + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + await expect(addPluginDialog).toBeHidden(); + + const pluginsSection = page.getByRole('group', { name: 'Plugins' }); + const basicAuthPlugin = pluginsSection.getByTestId('plugin-basic-auth'); + await basicAuthPlugin.getByRole('button', { name: 'Edit' }).click(); + + // should show edit plugin dialog + const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' }); + await expect(editorLoading).toBeHidden(); + + await expect(editPluginDialog.getByText('hide_credentials')).toBeVisible(); + // save edit plugin dialog + await editPluginDialog.getByRole('button', { name: 'Save' }).click(); + await expect(editPluginDialog).toBeHidden(); + + // delete basic-auth plugin + await basicAuthPlugin.getByRole('button', { name: 'Delete' }).click(); + await expect(basicAuthPlugin).toBeHidden(); + + // add real-ip plugin + await selectPluginsBtn.click(); + + await searchInput.fill('real-ip'); + await selectPluginsDialog + .getByTestId('plugin-real-ip') + .getByRole('button', { name: 'Add' }) + .click(); + // real-ip need config, otherwise it will show an error + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + await expect(addPluginDialog).toBeVisible(); + await expect(editorLoading).toBeHidden(); + await expect( + addPluginDialog.getByText('Missing property "source"') + ).toBeVisible(); + + // clear the editor, will show JSON format is not valid + await clearEditor(); + await editor.fill(''); + await expect( + addPluginDialog.getByText('JSON format is not valid') + ).toBeVisible(); + // try add, will show invalid configuration + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + await expect(addPluginDialog).toBeVisible(); + await expect( + addPluginDialog.getByText('JSON format is not valid') + ).toBeVisible(); + + // add a valid config + await editor.fill('{"source": "X-Forwarded-For"}'); + await addPluginDialog.getByRole('button', { name: 'Add' }).click(); + await expect(addPluginDialog).toBeHidden(); + + // check real-ip plugin in edit dialog + const realIpPlugin = page.getByTestId('plugin-real-ip'); + await realIpPlugin.getByRole('button', { name: 'Edit' }).click(); + await expect(editPluginDialog).toBeVisible(); + await expect(editorLoading).toBeHidden(); + await expect(editPluginDialog.getByText('X-Forwarded-For')).toBeVisible(); + // close + await editPluginDialog.getByRole('button', { name: 'Save' }).click(); + await expect(editPluginDialog).toBeHidden(); + + // Submit the form + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + }); + + await test.step('verify route in list page after creation', async () => { + // After creation, we should be redirected to the routes list page + await routesPom.isIndexPage(page); + + // Verify our newly created route appears in the list + await expect( + page.getByRole('cell', { name: routeNameWithAllFields }) + ).toBeVisible(); + }); + + await test.step('navigate to route detail page and verify all fields', async () => { + // Click on the route name to go to the detail page + await page + .getByRole('row', { name: routeNameWithAllFields }) + .getByRole('button', { name: 'View' }) + .click(); + await routesPom.isDetailPage(page); + + // Verify the route details + // Verify ID exists + const ID = page.getByRole('textbox', { name: 'ID', exact: true }); + await expect(ID).toBeVisible(); + await expect(ID).toBeDisabled(); + + // Verify the route name + const name = page.getByLabel('Name', { exact: true }).first(); + await expect(name).toHaveValue(routeNameWithAllFields); + await expect(name).toBeDisabled(); + + // Verify the description + const desc = page.getByLabel('Description').first(); + await expect(desc).toHaveValue(description); + await expect(desc).toBeDisabled(); + + // Verify the route URI + const uri = page.getByLabel('URI', { exact: true }); + await expect(uri).toHaveValue(routeUri); + await expect(uri).toBeDisabled(); + + // Verify HTTP methods + const methods = page + .getByRole('textbox', { name: 'HTTP Methods' }) + .locator('..'); + await expect(methods).toContainText('GET'); + await expect(methods).toContainText('POST'); + await expect(methods).toContainText('PUT'); + await expect(methods).toContainText('DELETE'); + + // Verify Host + await expect(page.getByLabel('Host', { exact: true }).first()).toHaveValue( + 'example.com' + ); + + // Verify Remote Address + await expect( + page.getByLabel('Remote Address', { exact: true }).first() + ).toHaveValue('192.168.1.0/24'); + + // Verify Priority + await expect( + page.getByLabel('Priority', { exact: true }).first() + ).toHaveValue('100'); + + // Verify Status + const status = page.getByRole('textbox', { name: 'Status', exact: true }); + await expect(status).toHaveValue('Disabled'); + + // Verify Plugins + await expect(page.getByText('basic-auth')).toBeHidden(); + await expect(page.getByText('real-ip')).toBeVisible(); + }); + + await test.step('edit and update route in detail page', async () => { + // Click the Edit button in the detail page + await page.getByRole('button', { name: 'Edit' }).click(); + + // Verify we're in edit mode - fields should be editable now + const nameField = page.getByLabel('Name', { exact: true }).first(); + await expect(nameField).toBeEnabled(); + + // Update the description field + const descriptionField = page.getByLabel('Description').first(); + await descriptionField.fill('Updated description for testing all fields'); + + // Update URI + const uriField = page.getByLabel('URI', { exact: true }); + await uriField.fill(`${routeUri}-updated`); + + // Update Host + await page + .getByLabel('Host', { exact: true }) + .first() + .fill('updated-example.com'); + + // Update Priority + await page.getByLabel('Priority', { exact: true }).first().fill('200'); + + // Click the Save button to save changes + const saveBtn = page.getByRole('button', { name: 'Save' }); + await saveBtn.click(); + + // Verify the update was successful + await uiHasToastMsg(page, { + hasText: 'success', + }); + + // Verify we're back in detail view mode + await routesPom.isDetailPage(page); + + // Verify the updated fields + // Verify description + await expect(page.getByLabel('Description').first()).toHaveValue( + 'Updated description for testing all fields' + ); + + // Check if the updated URI is visible + await expect(page.getByLabel('URI', { exact: true })).toHaveValue( + `${routeUri}-updated` + ); + + // Verify updated Host + await expect(page.getByLabel('Host', { exact: true }).first()).toHaveValue( + 'updated-example.com' + ); + + // Verify updated Priority + await expect( + page.getByLabel('Priority', { exact: true }).first() + ).toHaveValue('200'); + + // Return to list page and verify the route exists + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + + // Find the row with our route + const row = page.getByRole('row', { name: routeNameWithAllFields }); + await expect(row).toBeVisible(); + }); + + await test.step('delete route in detail page', async () => { + // Navigate to detail page + await page + .getByRole('row', { name: routeNameWithAllFields }) + .getByRole('button', { name: 'View' }) + .click(); + await routesPom.isDetailPage(page); + + // Delete the route + await page.getByRole('button', { name: 'Delete' }).click(); + + await page + .getByRole('dialog', { name: 'Delete Route' }) + .getByRole('button', { name: 'Delete' }) + .click(); + + // Will redirect to routes page + await routesPom.isIndexPage(page); + await uiHasToastMsg(page, { + hasText: 'Delete Route Successfully', + }); + await expect( + page.getByRole('cell', { name: routeNameWithAllFields }) + ).toBeHidden(); + + // Final verification: Reload the page and check again to ensure it's really gone + await page.reload(); + await routesPom.isIndexPage(page); + + // After reload, the route should still be gone + await expect( + page.getByRole('cell', { name: routeNameWithAllFields }) + ).toBeHidden(); + }); +}); diff --git a/e2e/tests/routes.crud-required-fields.spec.ts b/e2e/tests/routes.crud-required-fields.spec.ts new file mode 100644 index 000000000..c2123ce6f --- /dev/null +++ b/e2e/tests/routes.crud-required-fields.spec.ts @@ -0,0 +1,182 @@ +/** + * 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 { routesPom } from '@e2e/pom/routes'; +import { randomId } from '@e2e/utils/common'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +const routeName = randomId('test-route'); +const routeUri = '/test-route'; +const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'test.com', port: 80, weight: 100 }, + { host: 'test2.com', port: 80, weight: 100 }, +]; + +test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); +}); + +test('should CRUD route with required fields', async ({ page }) => { + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + await routesPom.getAddRouteBtn(page).click(); + await routesPom.isAddPage(page); + + await test.step('cannot submit without required fields', async () => { + await routesPom.getAddBtn(page).click(); + await routesPom.isAddPage(page); + await uiHasToastMsg(page, { + hasText: 'invalid configuration', + }); + }); + + await test.step('submit with required fields', async () => { + // Fill in the Name field + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + + // Select HTTP method + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + + // Add upstream nodes + // Reusing the pattern from upstreams test + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields(upstreamSection, { + nodes, + name: 'test-upstream', + desc: 'test', + }); + // Submit the form + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + }); + + await test.step('redirects to routes list page after creation', async () => { + // After creation, we should be redirected to the routes list page + await routesPom.isIndexPage(page); + + // Verify our newly created route appears in the list + await expect(page.getByRole('cell', { name: routeName })).toBeVisible(); + }); + + // We've already verified the route is in the list page in the previous step + + await test.step('navigate to route detail page', async () => { + // Click on the route name to go to the detail page + await page + .getByRole('row', { name: routeName }) + .getByRole('button', { name: 'View' }) + .click(); + await routesPom.isDetailPage(page); + + // Verify the route details + // Verify ID exists + const ID = page.getByRole('textbox', { name: 'ID', exact: true }); + await expect(ID).toBeVisible(); + await expect(ID).toBeDisabled(); + + // Verify the route name + const name = page.getByLabel('Name', { exact: true }).first(); + await expect(name).toHaveValue(routeName); + await expect(name).toBeDisabled(); + + // Verify the route URI + const uri = page.getByLabel('URI', { exact: true }); + await expect(uri).toHaveValue(routeUri); + await expect(uri).toBeDisabled(); + }); + + await test.step('edit and update route in detail page', async () => { + // Click the Edit button in the detail page + await page.getByRole('button', { name: 'Edit' }).click(); + + // Verify we're in edit mode - fields should be editable now + const nameField = page.getByLabel('Name', { exact: true }).first(); + await expect(nameField).toBeEnabled(); + + // Update the description field + const descriptionField = page.getByLabel('Description').first(); + await descriptionField.fill('Updated description for testing'); + + // Update URI + const uriField = page.getByLabel('URI', { exact: true }); + await uriField.fill(`${routeUri}-updated`); + + // Click the Save button to save changes + const saveBtn = page.getByRole('button', { name: 'Save' }); + await saveBtn.click(); + + // Verify the update was successful + await uiHasToastMsg(page, { + hasText: 'success', + }); + + // Verify we're back in detail view mode + await routesPom.isDetailPage(page); + + // Verify the updated fields + // Verify description + await expect(page.getByLabel('Description').first()).toHaveValue( + 'Updated description for testing' + ); + + // Check if the updated URI is visible + await expect(page.getByLabel('URI', { exact: true })).toHaveValue( + `${routeUri}-updated` + ); + + // Return to list page and verify the route exists + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + + // Find the row with our route + const row = page.getByRole('row', { name: routeName }); + await expect(row).toBeVisible(); + }); + + await test.step('delete route in detail page', async () => { + // We're already on the detail page from the previous step + + // Delete the route + await page.getByRole('button', { name: 'Delete' }).click(); + + await page + .getByRole('dialog', { name: 'Delete Route' }) + .getByRole('button', { name: 'Delete' }) + .click(); + + // Will redirect to routes page + await routesPom.isIndexPage(page); + await uiHasToastMsg(page, { + hasText: 'Delete Route Successfully', + }); + await expect(page.getByRole('cell', { name: routeName })).toBeHidden(); + }); +}); diff --git a/e2e/tests/upstreams.crud-all-fields.spec.ts b/e2e/tests/upstreams.crud-all-fields.spec.ts index d94432954..213172d51 100644 --- a/e2e/tests/upstreams.crud-all-fields.spec.ts +++ b/e2e/tests/upstreams.crud-all-fields.spec.ts @@ -15,11 +15,15 @@ * limitations under the License. */ import { upstreamsPom } from '@e2e/pom/upstreams'; -import { genTLS, randomId } from '@e2e/utils/common'; +import { randomId } from '@e2e/utils/common'; import { e2eReq } from '@e2e/utils/req'; import { test } from '@e2e/utils/test'; import { uiHasToastMsg } from '@e2e/utils/ui'; -import { expect, type Locator } from '@playwright/test'; +import { + uiCheckUpstreamAllFields, + uiFillUpstreamAllFields, +} from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; import { deleteAllUpstreams } from '@/apis/upstreams'; @@ -30,13 +34,6 @@ test.beforeAll(async () => { test('should CRUD upstream with all fields', async ({ page }) => { test.setTimeout(30000); - const fillHTTPStatuses = async (input: Locator, ...statuses: string[]) => { - for (const status of statuses) { - await input.fill(status); - await input.press('Enter'); - } - }; - const upstreamNameWithAllFields = randomId('test-upstream-full'); const description = 'This is a test description for the upstream with all fields'; @@ -49,220 +46,9 @@ test('should CRUD upstream with all fields', async ({ page }) => { await upstreamsPom.getAddUpstreamBtn(page).click(); await upstreamsPom.isAddPage(page); - await test.step('fill in required fields', async () => { - // Fill in the required fields - // 1. Name (required) - await page - .getByLabel('Name', { exact: true }) - .fill(upstreamNameWithAllFields); - - // 2. Description (optional but simple) - await page.getByLabel('Description').fill(description); - - // 3. Add multiple nodes (required) - const addNodeBtn = page.getByRole('button', { name: 'Add a Node' }); - const nodesSection = page.getByRole('group', { name: 'Nodes' }); - - // Wait for 'No Data' text to be visible - const noData = nodesSection.getByText('No Data'); - await expect(noData).toBeVisible(); - - // Add the first node, using force option - await addNodeBtn.click(); - await expect(noData).toBeHidden(); - - // Wait for table rows to appear - const rows = nodesSection.locator('tr.ant-table-row'); - await expect(rows.first()).toBeVisible(); - - // Fill in the Host for the first node - click first then fill - const hostInput = rows.first().locator('input').first(); - await hostInput.click(); - await hostInput.fill('node1.example.com'); - await expect(hostInput).toHaveValue('node1.example.com'); - - // Fill in the Port for the first node - click first then fill - const portInput = rows.first().locator('input').nth(1); - await portInput.click(); - await portInput.fill('8080'); - await expect(portInput).toHaveValue('8080'); - - // Fill in the Weight for the first node - click first then fill - const weightInput = rows.first().locator('input').nth(2); - await weightInput.click(); - await weightInput.fill('10'); - await expect(weightInput).toHaveValue('10'); - - // Fill in the Priority for the first node - click first then fill - const priorityInput = rows.first().locator('input').nth(3); - await priorityInput.click(); - await priorityInput.fill('1'); - - // Add the second node with a more reliable approach - await nodesSection.click(); - await addNodeBtn.click(); - - await expect(rows.nth(1)).toBeVisible(); - - // Fill in the Host for the second node - click first then fill - const hostInput2 = rows.nth(1).locator('input').first(); - await hostInput2.click(); - await hostInput2.fill('node2.example.com'); - await expect(hostInput2).toHaveValue('node2.example.com'); - - // Fill in the Port for the second node - click first then fill - const portInput2 = rows.nth(1).locator('input').nth(1); - await portInput2.click(); - await portInput2.fill('8081'); - await expect(portInput2).toHaveValue('8081'); - - // Fill in the Weight for the second node - click first then fill - const weightInput2 = rows.nth(1).locator('input').nth(2); - await weightInput2.click(); - await weightInput2.fill('5'); - await expect(weightInput2).toHaveValue('5'); - - // Fill in the Priority for the second node - click first then fill - const priorityInput2 = rows.nth(1).locator('input').nth(3); - await priorityInput2.click(); - await priorityInput2.fill('2'); - await expect(priorityInput2).toHaveValue('2'); - }); - - await test.step('fill in all optional fields', async () => { - // Fill in all optional fields - - // 1. Load balancing type - using force option - await page - .getByRole('textbox', { name: 'Type', exact: true }) - .scrollIntoViewIfNeeded(); - await page.getByRole('textbox', { name: 'Type', exact: true }).click(); - await page.getByRole('option', { name: 'chash' }).click(); - - // 2. Hash On field (only useful when type is chash) - using force option - await page.getByRole('textbox', { name: 'Hash On' }).click(); - await page.getByRole('option', { name: 'header' }).click(); - - // 3. Key field (only useful when type is chash) - await page - .getByRole('textbox', { name: 'Key', exact: true }) - .fill('X-Custom-Header'); - - // 4. Set protocol (Scheme) - using force option - await page.getByRole('textbox', { name: 'Scheme' }).click(); - await page.getByRole('option', { name: 'https' }).click(); - - // 5. Set retry count (Retries) - await page.getByLabel('Retries').fill('5'); - - // 6. Set retry timeout (Retry Timeout) - await page.getByLabel('Retry Timeout').fill('6'); - - // 7. Pass Host setting - using force option - await page.getByRole('textbox', { name: 'Pass Host' }).click(); - await page.getByRole('option', { name: 'rewrite' }).click(); - - // 8. Upstream Host - await page.getByLabel('Upstream Host').fill('custom.upstream.host'); - - // 9. Timeout settings - const timeoutSection = page.getByRole('group', { name: 'Timeout' }); - await timeoutSection.getByLabel('Connect').fill('3'); - await timeoutSection.getByLabel('Send').fill('3'); - await timeoutSection.getByLabel('Read').fill('3'); - - // 10. Keepalive Pool settings - const keepaliveSection = page.getByRole('group', { - name: 'Keepalive Pool', - }); - await keepaliveSection.getByLabel('Size').fill('320'); - await keepaliveSection.getByLabel('Idle Timeout').fill('60'); - await keepaliveSection.getByLabel('Requests').fill('1000'); - - // 11. TLS client verification settings - const tlsSection = page.getByRole('group', { name: 'TLS' }); - const tls = genTLS(); - await tlsSection - .getByRole('textbox', { name: 'Client Cert', exact: true }) - .fill(tls.cert); - await tlsSection - .getByRole('textbox', { name: 'Client Key', exact: true }) - .fill(tls.key); - await tlsSection - .locator('label') - .filter({ hasText: 'Verify' }) - .locator('div') - .first() - .click(); - - // 12. Health Check settings - // Activate active health check - const healthCheckSection = page.getByRole('group', { - name: 'Health Check', - }); - const checksEnabled = page.getByTestId('checksEnabled').locator('..'); - await checksEnabled.click(); - - // Set the Healthy part of Active health check settings - const activeSection = healthCheckSection.getByRole('group', { - name: 'Active', - }); - await activeSection - .getByRole('textbox', { name: 'Type', exact: true }) - .click(); - await page.getByRole('option', { name: 'http', exact: true }).click(); - - await activeSection.getByLabel('Timeout', { exact: true }).fill('5'); - await activeSection.getByLabel('Concurrency', { exact: true }).fill('2'); - await activeSection - .getByLabel('Host', { exact: true }) - .fill('health.example.com'); - await activeSection.getByLabel('Port', { exact: true }).fill('8888'); - await activeSection - .getByLabel('HTTP Path', { exact: true }) - .fill('/health'); - - // Set the Unhealthy part of Active health check settings - const activeUnhealthySection = activeSection.getByRole('group', { - name: 'Unhealthy', - }); - await activeUnhealthySection.getByLabel('Interval').fill('1'); - await activeUnhealthySection.getByLabel('HTTP Failures').fill('3'); - await activeUnhealthySection.getByLabel('TCP Failures').fill('3'); - await activeUnhealthySection.getByLabel('Timeouts').fill('3'); - await fillHTTPStatuses( - activeUnhealthySection.getByLabel('HTTP Statuses'), - '429', - '500', - '503' - ); - - // Activate passive health check - await healthCheckSection - .getByTestId('checksPassiveEnabled') - .locator('..') - .click(); - - // Set the Healthy part of Passive health check settings - const passiveSection = healthCheckSection.getByRole('group', { - name: 'Passive', - }); - await passiveSection - .getByRole('textbox', { name: 'Type', exact: true }) - .click(); - await page.getByRole('option', { name: 'http', exact: true }).click(); - - // Set the Unhealthy part of Passive health check settings - const passiveUnhealthySection = passiveSection.getByRole('group', { - name: 'Unhealthy', - }); - await passiveUnhealthySection.getByLabel('HTTP Failures').fill('3'); - await passiveUnhealthySection.getByLabel('TCP Failures').fill('3'); - await passiveUnhealthySection.getByLabel('Timeouts').fill('3'); - await fillHTTPStatuses( - passiveUnhealthySection.getByLabel('HTTP Statuses'), - '500' - ); + await uiFillUpstreamAllFields(test, page, { + name: upstreamNameWithAllFields, + desc: description, }); // Submit the form @@ -278,215 +64,10 @@ test('should CRUD upstream with all fields', async ({ page }) => { await upstreamsPom.isDetailPage(page); await test.step('verify all fields in detail page', async () => { - // Verify basic information - const name = page.getByLabel('Name', { exact: true }); - await expect(name).toHaveValue(upstreamNameWithAllFields); - await expect(name).toBeDisabled(); - - const descriptionField = page.getByLabel('Description'); - await expect(descriptionField).toHaveValue(description); - await expect(descriptionField).toBeDisabled(); - - // Verify node information - const nodesSection = page.getByRole('group', { name: 'Nodes' }); - await expect( - nodesSection.getByRole('cell', { name: 'node1.example.com' }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '8080' }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '10', exact: true }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '1', exact: true }) - ).toBeVisible(); - - await expect( - nodesSection.getByRole('cell', { name: 'node2.example.com' }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '8081' }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '5', exact: true }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: '2', exact: true }) - ).toBeVisible(); - - // Verify load balancing type - const loadBalancingSection = page.getByRole('group', { - name: 'Load Balancing', - }); - const typeField = loadBalancingSection.getByRole('textbox', { - name: 'Type', - exact: true, - }); - await expect(typeField).toHaveValue('chash'); - await expect(typeField).toBeDisabled(); - - // Verify Hash On field - const hashOnField = loadBalancingSection.getByRole('textbox', { - name: 'Hash On', - exact: true, - }); - await expect(hashOnField).toHaveValue('header'); - await expect(hashOnField).toBeDisabled(); - - // Verify Key field - const keyField = loadBalancingSection.getByLabel('Key'); - await expect(keyField).toHaveValue('X-Custom-Header'); - await expect(keyField).toBeDisabled(); - - // Verify protocol (Scheme) - const schemeField = page.getByRole('textbox', { - name: 'Scheme', - exact: true, - }); - await expect(schemeField).toHaveValue('https'); - await expect(schemeField).toBeDisabled(); - - // Verify retry count field (Retries) - const retriesField = page.getByLabel('Retries'); - await expect(retriesField).toHaveValue('5'); - await expect(retriesField).toBeDisabled(); - - // Verify retry timeout field (Retry Timeout) - const retryTimeoutField = page.getByLabel('Retry Timeout'); - await expect(retryTimeoutField).toHaveValue('6s'); - await expect(retryTimeoutField).toBeDisabled(); - - // Verify Pass Host field - const passHostSection = page.getByRole('group', { name: 'Pass Host' }); - const passHostField = passHostSection.getByRole('textbox', { - name: 'Pass Host', - exact: true, - }); - await expect(passHostField).toHaveValue('rewrite'); - await expect(passHostField).toBeDisabled(); - - // Verify Upstream Host field - const upstreamHostField = page.getByLabel('Upstream Host'); - await expect(upstreamHostField).toHaveValue('custom.upstream.host'); - await expect(upstreamHostField).toBeDisabled(); - - // Verify timeout settings (Timeout) - const timeoutSection = page.getByRole('group', { name: 'Timeout' }); - await expect(timeoutSection.getByLabel('Connect')).toHaveValue('3s'); - await expect(timeoutSection.getByLabel('Send')).toHaveValue('3s'); - await expect(timeoutSection.getByLabel('Read')).toHaveValue('3s'); - - // Verify keepalive pool settings (Keepalive Pool) - const keepaliveSection = page.getByRole('group', { - name: 'Keepalive Pool', + await uiCheckUpstreamAllFields(page, { + name: upstreamNameWithAllFields, + desc: description, }); - await expect(keepaliveSection.getByLabel('Size')).toHaveValue('320'); - await expect(keepaliveSection.getByLabel('Idle Timeout')).toHaveValue( - '60s' - ); - await expect(keepaliveSection.getByLabel('Requests')).toHaveValue('1000'); - - // Verify TLS settings - const tlsSection = page.getByRole('group', { name: 'TLS' }); - await expect(tlsSection.getByLabel('Verify')).toBeChecked(); - - // Verify health check settings - const healthCheckSection = page.getByRole('group', { - name: 'Health Check', - }); - // Check if Active and Passive health checks are enabled (by checking if the respective sections exist) - await expect( - healthCheckSection.getByRole('group', { name: 'Active' }) - ).toBeVisible(); - await expect( - healthCheckSection.getByRole('group', { name: 'Passive' }) - ).toBeVisible(); - - // Verify active health check settings - const activeSection = healthCheckSection.getByRole('group', { - name: 'Active', - }); - const activeTypeField = activeSection.getByRole('textbox', { - name: 'Type', - exact: true, - }); - await expect(activeTypeField).toHaveValue('http'); - // Use more specific selectors for Timeout to avoid ambiguity - await expect( - activeSection.getByRole('textbox', { name: 'Timeout', exact: true }) - ).toHaveValue('5s'); - await expect(activeSection.getByLabel('Concurrency')).toHaveValue('2'); - await expect(activeSection.getByLabel('Host')).toHaveValue( - 'health.example.com' - ); - await expect(activeSection.getByLabel('Port')).toHaveValue('8888'); - await expect(activeSection.getByLabel('HTTP Path')).toHaveValue('/health'); - - // Verify passive health check settings - const passiveSection = healthCheckSection.getByRole('group', { - name: 'Passive', - }); - - // Verify active health check - healthy status settings - const activeHealthySection = activeSection.getByRole('group', { - name: 'Healthy', - }); - // Check if the Successes field exists rather than its exact value - // This is more resilient to UI differences - await expect(activeHealthySection.getByLabel('Successes')).toBeVisible(); - - // Verify active health check - unhealthy status settings - const activeUnhealthySection = activeSection.getByRole('group', { - name: 'Unhealthy', - }); - // Check if the fields exist rather than their exact values - // This is more resilient to UI differences - await expect( - activeUnhealthySection.getByLabel('HTTP Failures') - ).toBeVisible(); - await expect( - activeUnhealthySection.getByLabel('TCP Failures') - ).toBeVisible(); - await expect(activeUnhealthySection.getByLabel('Timeouts')).toBeVisible(); - // Skip HTTP Statuses verification since the format might be different in detail view - - // Verify passive health check settings - const passiveTypeField = passiveSection.getByRole('textbox', { - name: 'Type', - exact: true, - }); - // Check if the Type field exists and is visible - await expect(passiveTypeField).toBeVisible(); - - // Verify passive health check - healthy status settings - const passiveHealthySection = passiveSection.getByRole('group', { - name: 'Healthy', - }); - // Check if the Successes field exists rather than its exact value - await expect(passiveHealthySection.getByLabel('Successes')).toBeVisible(); - - // Verify passive health check - unhealthy status settings - const passiveUnhealthySection = passiveSection.getByRole('group', { - name: 'Unhealthy', - }); - // Check if the fields exist rather than their exact values - await expect( - passiveUnhealthySection.getByLabel('HTTP Failures') - ).toBeVisible(); - await expect( - passiveUnhealthySection.getByLabel('TCP Failures') - ).toBeVisible(); - await expect(passiveUnhealthySection.getByLabel('Timeouts')).toBeVisible(); - - // Verify that the HTTP Statuses section exists in some form - // We'll use a more general selector that should work regardless of the exact UI structure - await expect( - passiveSection.getByRole('group', { name: 'Unhealthy' }) - ).toBeVisible(); - - // Note: We're not checking for specific HTTP status codes like '500' - // as the format might be different in the detail view }); await test.step('return to list page and verify', async () => { diff --git a/e2e/tests/upstreams.crud-required-fields.spec.ts b/e2e/tests/upstreams.crud-required-fields.spec.ts index 4581e21c9..e9a02e4af 100644 --- a/e2e/tests/upstreams.crud-required-fields.spec.ts +++ b/e2e/tests/upstreams.crud-required-fields.spec.ts @@ -18,7 +18,11 @@ import { upstreamsPom } from '@e2e/pom/upstreams'; import { randomId } from '@e2e/utils/common'; import { e2eReq } from '@e2e/utils/req'; import { test } from '@e2e/utils/test'; -import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiCannotSubmitEmptyForm, uiHasToastMsg } from '@e2e/utils/ui'; +import { + uiCheckUpstreamRequiredFields, + uiFillUpstreamRequiredFields, +} from '@e2e/utils/ui/upstreams'; import { expect } from '@playwright/test'; import { deleteAllUpstreams } from '@/apis/upstreams'; @@ -41,46 +45,16 @@ test('should CRUD upstream with required fields', async ({ page }) => { await upstreamsPom.getAddUpstreamBtn(page).click(); await upstreamsPom.isAddPage(page); - const addBtn = page.getByRole('button', { name: 'Add', exact: true }); await test.step('cannot submit without required fields', async () => { - await addBtn.click(); - await upstreamsPom.isAddPage(page); - await uiHasToastMsg(page, { - hasText: 'invalid configuration: value ', - }); + await uiCannotSubmitEmptyForm(page, upstreamsPom); }); await test.step('submit with required fields', async () => { - await page.getByLabel('Name', { exact: true }).fill(upstreamName); - - const nodesSection = page.getByRole('group', { name: 'Nodes' }); - const noData = nodesSection.getByText('No Data'); - const addNodeBtn = page.getByRole('button', { name: 'Add a Node' }); - - await expect(noData).toBeVisible(); - - await addNodeBtn.click(); - await expect(noData).toBeHidden(); - const rows = nodesSection.locator('tr.ant-table-row'); - const firstRowHost = rows.nth(0).getByRole('textbox').first(); - await firstRowHost.fill(nodes[1].host); - await expect(firstRowHost).toHaveValue(nodes[1].host); - await nodesSection.click(); - - // add a new node then remove it - await addNodeBtn.click(); - await expect(rows.nth(1)).toBeVisible(); - const secondRowHost = rows.nth(1).getByRole('textbox').first(); - await secondRowHost.fill(nodes[0].host); - await expect(secondRowHost).toHaveValue(nodes[0].host); - await nodesSection.click(); - - // we need to replace the antd component to help fix this issue - await addNodeBtn.click(); - rows.nth(2).getByRole('button', { name: 'Delete' }).click(); - await expect(rows).toHaveCount(2); - - await addBtn.click(); + await uiFillUpstreamRequiredFields(page, { + name: upstreamName, + nodes, + }); + await upstreamsPom.getAddBtn(page).click(); await uiHasToastMsg(page, { hasText: 'Add Upstream Successfully', }); @@ -92,19 +66,10 @@ test('should CRUD upstream with required fields', async ({ page }) => { const ID = page.getByRole('textbox', { name: 'ID', exact: true }); await expect(ID).toBeVisible(); await expect(ID).toBeDisabled(); - // Verify the upstream name - const name = page.getByLabel('Name', { exact: true }); - await expect(name).toHaveValue(upstreamName); - await expect(name).toBeDisabled(); - // Verify the upstream nodes - const nodesSection = page.getByRole('group', { name: 'Nodes' }); - - await expect( - nodesSection.getByRole('cell', { name: nodes[1].host }) - ).toBeVisible(); - await expect( - nodesSection.getByRole('cell', { name: nodes[0].host }) - ).toBeVisible(); + await uiCheckUpstreamRequiredFields(page, { + name: upstreamName, + nodes, + }); }); await test.step('can see upstream in list page', async () => { diff --git a/e2e/utils/test.ts b/e2e/utils/test.ts index 5abc1ad65..3e2a2d4c8 100644 --- a/e2e/utils/test.ts +++ b/e2e/utils/test.ts @@ -22,6 +22,7 @@ import { expect, test as baseTest } from '@playwright/test'; import { fileExists, getAPISIXConf } from './common'; import { env } from './env'; +export type Test = typeof test; export const test = baseTest.extend<object, { workerStorageState: string }>({ storageState: ({ workerStorageState }, use) => use(workerStorageState), workerStorageState: [ diff --git a/e2e/utils/ui.ts b/e2e/utils/ui/index.ts similarity index 73% rename from e2e/utils/ui.ts rename to e2e/utils/ui/index.ts index a683be6ef..80db6337d 100644 --- a/e2e/utils/ui.ts +++ b/e2e/utils/ui/index.ts @@ -14,11 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import type { CommonPOM } from '@e2e/pom/type'; import { expect, type Locator, type Page } from '@playwright/test'; import type { FileRouteTypes } from '@/routeTree.gen'; -import { env } from './env'; +import { env } from '../env'; export const uiGoto = (page: Page, path: FileRouteTypes['to']) => { return page.goto(`${env.E2E_TARGET_URL}${path.substring(1)}`); @@ -33,3 +34,21 @@ export const uiHasToastMsg = async ( await alertMsg.getByRole('button').click(); await expect(alertMsg).not.toBeVisible(); }; + +export async function uiCannotSubmitEmptyForm(page: Page, pom: CommonPOM) { + await pom.getAddBtn(page).click(); + await pom.isAddPage(page); + await uiHasToastMsg(page, { + hasText: 'invalid configuration', + }); +} + +export async function uiFillHTTPStatuses( + input: Locator, + ...statuses: string[] +) { + for (const status of statuses) { + await input.fill(status); + await input.press('Enter'); + } +} diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts new file mode 100644 index 000000000..480504396 --- /dev/null +++ b/e2e/utils/ui/upstreams.ts @@ -0,0 +1,517 @@ +/** + * 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 { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import type { APISIXType } from '@/types/schema/apisix'; + +import { genTLS } from '../common'; +import type { Test } from '../test'; +import { uiFillHTTPStatuses } from '.'; + +/** + * Fill the upstream form with required fields + * @param ctx - Playwright page object or locator + * @param upstreamName - Name for the upstream + * @param nodes - Array of upstream nodes + */ +export async function uiFillUpstreamRequiredFields( + ctx: Page | Locator, + upstream: Partial<APISIXType['Upstream']> +) { + // Fill in the Name field + await ctx.getByLabel('Name', { exact: true }).fill(upstream.name); + + // Configure nodes section + const nodesSection = ctx.getByRole('group', { name: 'Nodes' }); + const noData = nodesSection.getByText('No Data'); + const addNodeBtn = ctx.getByRole('button', { name: 'Add a Node' }); + + await expect(noData).toBeVisible(); + + // Add first node + await addNodeBtn.click(); + await expect(noData).toBeHidden(); + const rows = nodesSection.locator('tr.ant-table-row'); + const firstRowHost = rows.nth(0).getByRole('textbox').first(); + await firstRowHost.fill(upstream.nodes[1].host); + await expect(firstRowHost).toHaveValue(upstream.nodes[1].host); + await nodesSection.click(); + + // Add second node + await addNodeBtn.click(); + await expect(rows.nth(1)).toBeVisible(); + const secondRowHost = rows.nth(1).getByRole('textbox').first(); + await secondRowHost.fill(upstream.nodes[0].host); + await expect(secondRowHost).toHaveValue(upstream.nodes[0].host); + await nodesSection.click(); + + // Add a third node and then remove it to test deletion functionality + await addNodeBtn.click(); + rows.nth(2).getByRole('button', { name: 'Delete' }).click(); + await expect(rows).toHaveCount(2); +} + +export async function uiCheckUpstreamRequiredFields( + ctx: Page | Locator, + upstream: Partial<APISIXType['Upstream']> +) { + // Verify the upstream name + const name = ctx.getByLabel('Name', { exact: true }); + await expect(name).toHaveValue(upstream.name); + await expect(name).toBeDisabled(); + // Verify the upstream nodes + const nodesSection = ctx.getByRole('group', { name: 'Nodes' }); + + await expect( + nodesSection.getByRole('cell', { name: upstream.nodes[1].host }) + ).toBeVisible(); + await expect( + nodesSection.getByRole('cell', { name: upstream.nodes[0].host }) + ).toBeVisible(); +} + +export async function uiFillUpstreamAllFields( + test: Test, + ctx: Page | Locator, + /** + * currently only name and desc are useful, + * because I dont want to change too many fields in upstreams related tests + */ + upstream: Partial<APISIXType['Upstream']>, + page: Page = ctx as Page +) { + await test.step('fill in required fields', async () => { + // Fill in the required fields + // 1. Name (required) + await ctx.getByLabel('Name', { exact: true }).fill(upstream.name); + + // 2. Description (optional but simple) + await ctx.getByLabel('Description').fill(upstream.desc); + + // 3. Add multiple nodes (required) + const addNodeBtn = ctx.getByRole('button', { name: 'Add a Node' }); + const nodesSection = ctx.getByRole('group', { name: 'Nodes' }); + + // Wait for 'No Data' text to be visible + const noData = nodesSection.getByText('No Data'); + await expect(noData).toBeVisible(); + + // Add the first node, using force option + await addNodeBtn.click(); + await expect(noData).toBeHidden(); + + // Wait for table rows to appear + const rows = nodesSection.locator('tr.ant-table-row'); + await expect(rows.first()).toBeVisible(); + + // Fill in the Host for the first node - click first then fill + const hostInput = rows.first().locator('input').first(); + await hostInput.click(); + await hostInput.fill('node1.example.com'); + await expect(hostInput).toHaveValue('node1.example.com'); + + // Fill in the Port for the first node - click first then fill + const portInput = rows.first().locator('input').nth(1); + await portInput.click(); + await portInput.fill('8080'); + await expect(portInput).toHaveValue('8080'); + + // Fill in the Weight for the first node - click first then fill + const weightInput = rows.first().locator('input').nth(2); + await weightInput.click(); + await weightInput.fill('10'); + await expect(weightInput).toHaveValue('10'); + + // Fill in the Priority for the first node - click first then fill + const priorityInput = rows.first().locator('input').nth(3); + await priorityInput.click(); + await priorityInput.fill('1'); + + // Add the second node with a more reliable approach + await nodesSection.click(); + await addNodeBtn.click(); + + await expect(rows.nth(1)).toBeVisible(); + + // Fill in the Host for the second node - click first then fill + const hostInput2 = rows.nth(1).locator('input').first(); + await hostInput2.click(); + await hostInput2.fill('node2.example.com'); + await expect(hostInput2).toHaveValue('node2.example.com'); + + // Fill in the Port for the second node - click first then fill + const portInput2 = rows.nth(1).locator('input').nth(1); + await portInput2.click(); + await portInput2.fill('8081'); + await expect(portInput2).toHaveValue('8081'); + + // Fill in the Weight for the second node - click first then fill + const weightInput2 = rows.nth(1).locator('input').nth(2); + await weightInput2.click(); + await weightInput2.fill('5'); + await expect(weightInput2).toHaveValue('5'); + + // Fill in the Priority for the second node - click first then fill + const priorityInput2 = rows.nth(1).locator('input').nth(3); + await priorityInput2.click(); + await priorityInput2.fill('2'); + await expect(priorityInput2).toHaveValue('2'); + }); + + await test.step('fill in all optional fields', async () => { + // Fill in all optional fields + + // 1. Load balancing type - using force option + await ctx + .getByRole('textbox', { name: 'Type', exact: true }) + .scrollIntoViewIfNeeded(); + await ctx.getByRole('textbox', { name: 'Type', exact: true }).click(); + const chashOption = page.getByRole('option', { name: 'chash' }); + await expect(chashOption).toBeVisible(); + await chashOption.click(); + + // 2. Hash On field (only useful when type is chash) - using force option + await ctx.getByRole('textbox', { name: 'Hash On' }).click(); + await page.getByRole('option', { name: 'header' }).click(); + + // 3. Key field (only useful when type is chash) + await ctx + .getByRole('textbox', { name: 'Key', exact: true }) + .fill('X-Custom-Header'); + + // 4. Set protocol (Scheme) - using force option + await ctx.getByRole('textbox', { name: 'Scheme' }).click(); + await page.getByRole('option', { name: 'https' }).click(); + + // 5. Set retry count (Retries) + await ctx.getByLabel('Retries').fill('5'); + + // 6. Set retry timeout (Retry Timeout) + await ctx.getByLabel('Retry Timeout').fill('6'); + + // 7. Pass Host setting - using force option + await ctx.getByRole('textbox', { name: 'Pass Host' }).click(); + await page.getByRole('option', { name: 'rewrite' }).click(); + + // 8. Upstream Host + await ctx.getByLabel('Upstream Host').fill('custom.upstream.host'); + + // 9. Timeout settings + const timeoutSection = ctx.getByRole('group', { name: 'Timeout' }); + await timeoutSection.getByLabel('Connect').fill('3'); + await timeoutSection.getByLabel('Send').fill('3'); + await timeoutSection.getByLabel('Read').fill('3'); + + // 10. Keepalive Pool settings + const keepaliveSection = ctx.getByRole('group', { + name: 'Keepalive Pool', + }); + await keepaliveSection.getByLabel('Size').fill('320'); + await keepaliveSection.getByLabel('Idle Timeout').fill('60'); + await keepaliveSection.getByLabel('Requests').fill('1000'); + + // 11. TLS client verification settings + const tlsSection = ctx.getByRole('group', { name: 'TLS' }); + const tls = genTLS(); + await tlsSection + .getByRole('textbox', { name: 'Client Cert', exact: true }) + .fill(tls.cert); + await tlsSection + .getByRole('textbox', { name: 'Client Key', exact: true }) + .fill(tls.key); + await tlsSection + .locator('label') + .filter({ hasText: 'Verify' }) + .locator('div') + .first() + .click(); + + // 12. Health Check settings + // Activate active health check + const healthCheckSection = ctx.getByRole('group', { + name: 'Health Check', + }); + const checksEnabled = ctx.getByTestId('checksEnabled').locator('..'); + await checksEnabled.click(); + + // Set the Healthy part of Active health check settings + const activeSection = healthCheckSection.getByRole('group', { + name: 'Active', + }); + await activeSection + .getByRole('textbox', { name: 'Type', exact: true }) + .click(); + await page.getByRole('option', { name: 'http', exact: true }).click(); + + await activeSection.getByLabel('Timeout', { exact: true }).fill('5'); + await activeSection.getByLabel('Concurrency', { exact: true }).fill('2'); + await activeSection + .getByLabel('Host', { exact: true }) + .fill('health.example.com'); + await activeSection.getByLabel('Port', { exact: true }).fill('8888'); + await activeSection + .getByLabel('HTTP Path', { exact: true }) + .fill('/health'); + + // Set the Unhealthy part of Active health check settings + const activeUnhealthySection = activeSection.getByRole('group', { + name: 'Unhealthy', + }); + await activeUnhealthySection.getByLabel('Interval').fill('1'); + await activeUnhealthySection.getByLabel('HTTP Failures').fill('3'); + await activeUnhealthySection.getByLabel('TCP Failures').fill('3'); + await activeUnhealthySection.getByLabel('Timeouts').fill('3'); + await uiFillHTTPStatuses( + activeUnhealthySection.getByLabel('HTTP Statuses'), + '429', + '500', + '503' + ); + + // Activate passive health check + await healthCheckSection + .getByTestId('checksPassiveEnabled') + .locator('..') + .click(); + + // Set the Healthy part of Passive health check settings + const passiveSection = healthCheckSection.getByRole('group', { + name: 'Passive', + }); + await passiveSection + .getByRole('textbox', { name: 'Type', exact: true }) + .click(); + await page.getByRole('option', { name: 'http', exact: true }).click(); + + // Set the Unhealthy part of Passive health check settings + const passiveUnhealthySection = passiveSection.getByRole('group', { + name: 'Unhealthy', + }); + await passiveUnhealthySection.getByLabel('HTTP Failures').fill('3'); + await passiveUnhealthySection.getByLabel('TCP Failures').fill('3'); + await passiveUnhealthySection.getByLabel('Timeouts').fill('3'); + await uiFillHTTPStatuses( + passiveUnhealthySection.getByLabel('HTTP Statuses'), + '500' + ); + }); +} + +export async function uiCheckUpstreamAllFields( + ctx: Page | Locator, + upstream: Partial<APISIXType['Upstream']> +) { + // Verify basic information + const name = ctx.getByLabel('Name', { exact: true }); + await expect(name).toHaveValue(upstream.name); + await expect(name).toBeDisabled(); + + const descriptionField = ctx.getByLabel('Description'); + await expect(descriptionField).toHaveValue(upstream.desc); + await expect(descriptionField).toBeDisabled(); + + // Verify node information + const nodesSection = ctx.getByRole('group', { name: 'Nodes' }); + await expect( + nodesSection.getByRole('cell', { name: 'node1.example.com' }) + ).toBeVisible(); + await expect(nodesSection.getByRole('cell', { name: '8080' })).toBeVisible(); + await expect( + nodesSection.getByRole('cell', { name: '10', exact: true }) + ).toBeVisible(); + await expect( + nodesSection.getByRole('cell', { name: '1', exact: true }) + ).toBeVisible(); + + await expect( + nodesSection.getByRole('cell', { name: 'node2.example.com' }) + ).toBeVisible(); + await expect(nodesSection.getByRole('cell', { name: '8081' })).toBeVisible(); + await expect( + nodesSection.getByRole('cell', { name: '5', exact: true }) + ).toBeVisible(); + await expect( + nodesSection.getByRole('cell', { name: '2', exact: true }) + ).toBeVisible(); + + // Verify load balancing type + const loadBalancingSection = ctx.getByRole('group', { + name: 'Load Balancing', + }); + const typeField = loadBalancingSection.getByRole('textbox', { + name: 'Type', + exact: true, + }); + await expect(typeField).toHaveValue('chash'); + await expect(typeField).toBeDisabled(); + + // Verify Hash On field + const hashOnField = loadBalancingSection.getByRole('textbox', { + name: 'Hash On', + exact: true, + }); + await expect(hashOnField).toHaveValue('header'); + await expect(hashOnField).toBeDisabled(); + + // Verify Key field + const keyField = loadBalancingSection.getByLabel('Key'); + await expect(keyField).toHaveValue('X-Custom-Header'); + await expect(keyField).toBeDisabled(); + + // Verify protocol (Scheme) + const schemeField = ctx.getByRole('textbox', { + name: 'Scheme', + exact: true, + }); + await expect(schemeField).toHaveValue('https'); + await expect(schemeField).toBeDisabled(); + + // Verify retry count field (Retries) + const retriesField = ctx.getByLabel('Retries'); + await expect(retriesField).toHaveValue('5'); + await expect(retriesField).toBeDisabled(); + + // Verify retry timeout field (Retry Timeout) + const retryTimeoutField = ctx.getByLabel('Retry Timeout'); + await expect(retryTimeoutField).toHaveValue('6s'); + await expect(retryTimeoutField).toBeDisabled(); + + // Verify Pass Host field + const passHostSection = ctx.getByRole('group', { name: 'Pass Host' }); + const passHostField = passHostSection.getByRole('textbox', { + name: 'Pass Host', + exact: true, + }); + await expect(passHostField).toHaveValue('rewrite'); + await expect(passHostField).toBeDisabled(); + + // Verify Upstream Host field + const upstreamHostField = ctx.getByLabel('Upstream Host'); + await expect(upstreamHostField).toHaveValue('custom.upstream.host'); + await expect(upstreamHostField).toBeDisabled(); + + // Verify timeout settings (Timeout) + const timeoutSection = ctx.getByRole('group', { name: 'Timeout' }); + await expect(timeoutSection.getByLabel('Connect')).toHaveValue('3s'); + await expect(timeoutSection.getByLabel('Send')).toHaveValue('3s'); + await expect(timeoutSection.getByLabel('Read')).toHaveValue('3s'); + + // Verify keepalive pool settings (Keepalive Pool) + const keepaliveSection = ctx.getByRole('group', { + name: 'Keepalive Pool', + }); + await expect(keepaliveSection.getByLabel('Size')).toHaveValue('320'); + await expect(keepaliveSection.getByLabel('Idle Timeout')).toHaveValue('60s'); + await expect(keepaliveSection.getByLabel('Requests')).toHaveValue('1000'); + + // Verify TLS settings + const tlsSection = ctx.getByRole('group', { name: 'TLS' }); + await expect(tlsSection.getByLabel('Verify')).toBeChecked(); + + // Verify health check settings + const healthCheckSection = ctx.getByRole('group', { + name: 'Health Check', + }); + // Check if Active and Passive health checks are enabled (by checking if the respective sections exist) + await expect( + healthCheckSection.getByRole('group', { name: 'Active' }) + ).toBeVisible(); + await expect( + healthCheckSection.getByRole('group', { name: 'Passive' }) + ).toBeVisible(); + + // Verify active health check settings + const activeSection = healthCheckSection.getByRole('group', { + name: 'Active', + }); + const activeTypeField = activeSection.getByRole('textbox', { + name: 'Type', + exact: true, + }); + await expect(activeTypeField).toHaveValue('http'); + // Use more specific selectors for Timeout to avoid ambiguity + await expect( + activeSection.getByRole('textbox', { name: 'Timeout', exact: true }) + ).toHaveValue('5s'); + await expect(activeSection.getByLabel('Concurrency')).toHaveValue('2'); + await expect(activeSection.getByLabel('Host')).toHaveValue( + 'health.example.com' + ); + await expect(activeSection.getByLabel('Port')).toHaveValue('8888'); + await expect(activeSection.getByLabel('HTTP Path')).toHaveValue('/health'); + + // Verify passive health check settings + const passiveSection = healthCheckSection.getByRole('group', { + name: 'Passive', + }); + + // Verify active health check - healthy status settings + const activeHealthySection = activeSection.getByRole('group', { + name: 'Healthy', + }); + // Check if the Successes field exists rather than its exact value + // This is more resilient to UI differences + await expect(activeHealthySection.getByLabel('Successes')).toBeVisible(); + + // Verify active health check - unhealthy status settings + const activeUnhealthySection = activeSection.getByRole('group', { + name: 'Unhealthy', + }); + // Check if the fields exist rather than their exact values + // This is more resilient to UI differences + await expect( + activeUnhealthySection.getByLabel('HTTP Failures') + ).toBeVisible(); + await expect(activeUnhealthySection.getByLabel('TCP Failures')).toBeVisible(); + await expect(activeUnhealthySection.getByLabel('Timeouts')).toBeVisible(); + // Skip HTTP Statuses verification since the format might be different in detail view + + // Verify passive health check settings + const passiveTypeField = passiveSection.getByRole('textbox', { + name: 'Type', + exact: true, + }); + // Check if the Type field exists and is visible + await expect(passiveTypeField).toBeVisible(); + + // Verify passive health check - healthy status settings + const passiveHealthySection = passiveSection.getByRole('group', { + name: 'Healthy', + }); + // Check if the Successes field exists rather than its exact value + await expect(passiveHealthySection.getByLabel('Successes')).toBeVisible(); + + // Verify passive health check - unhealthy status settings + const passiveUnhealthySection = passiveSection.getByRole('group', { + name: 'Unhealthy', + }); + // Check if the fields exist rather than their exact values + await expect( + passiveUnhealthySection.getByLabel('HTTP Failures') + ).toBeVisible(); + await expect( + passiveUnhealthySection.getByLabel('TCP Failures') + ).toBeVisible(); + await expect(passiveUnhealthySection.getByLabel('Timeouts')).toBeVisible(); + + // Verify that the HTTP Statuses section exists in some form + // We'll use a more general selector that should work regardless of the exact UI structure + await expect( + passiveSection.getByRole('group', { name: 'Unhealthy' }) + ).toBeVisible(); +} diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx b/src/components/form-slice/FormItemPlugins/PluginCard.tsx index 964c7e894..2d9bca8f5 100644 --- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx @@ -31,7 +31,7 @@ export const PluginCard = (props: PluginCardProps) => { const { name, desc, mode, onAdd, onEdit, onView, onDelete } = props; const { t } = useTranslation(); return ( - <Card withBorder radius="md" p="md"> + <Card withBorder radius="md" p="md" data-testid={`plugin-${name}`}> <Card.Section withBorder inheritPadding py="xs"> <Group justify="space-between"> <Group> @@ -47,7 +47,7 @@ export const PluginCard = (props: PluginCardProps) => { <Group mt="md" justify="flex-end"> {mode === 'add' && ( <Button - size="xs" + size="compact-xs" variant="light" color="blue" onClick={() => onAdd?.(name)} @@ -56,14 +56,18 @@ export const PluginCard = (props: PluginCardProps) => { </Button> )} {mode === 'view' && ( - <Button size="xs" variant="light" onClick={() => onView?.(name)}> + <Button + size="compact-xs" + variant="light" + onClick={() => onView?.(name)} + > {t('form.btn.view')} </Button> )} {mode === 'edit' && ( <> <Button - size="xs" + size="compact-xs" variant="light" color="blue" onClick={() => onEdit?.(name)} @@ -71,7 +75,7 @@ export const PluginCard = (props: PluginCardProps) => { {t('form.btn.edit')} </Button> <Button - size="xs" + size="compact-xs" variant="light" color="red" onClick={() => onDelete?.(name)} diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx index ee2c50e2d..23742630a 100644 --- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx @@ -64,17 +64,21 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => { closeOnEscape={false} opened={opened} onClose={handleClose} + styles={{ body: { paddingTop: '18px' } }} {...(mode === 'add' && { title: t('form.plugins.addPlugin') })} {...(mode === 'edit' && { title: t('form.plugins.editPlugin') })} {...(mode === 'view' && { title: t('form.plugins.viewPlugin') })} > - <Title order={3}>{name}</Title> + <Title order={3} mb={10}> + {name} + </Title> <FormProvider {...methods}> <form> <FormItemEditor name="config" h={500} customSchema={schema} + isLoading={!schema} required /> </form> diff --git a/src/components/form-slice/FormItemPlugins/index.tsx b/src/components/form-slice/FormItemPlugins/index.tsx index 3b7a1b1f9..7186ad527 100644 --- a/src/components/form-slice/FormItemPlugins/index.tsx +++ b/src/components/form-slice/FormItemPlugins/index.tsx @@ -98,6 +98,7 @@ export const FormItemPlugins = <T extends FieldValues>( const { name, config: pluginConfig } = config; this.__map.set(name, pluginConfig); this.save(); + this.setSelectPluginsOpened(false); }, curPlugin: {} as PluginConfig, setCurPlugin(name: string) { @@ -118,7 +119,6 @@ export const FormItemPlugins = <T extends FieldValues>( }, closeEditor() { this.setEditorOpened(false); - this.setSelectPluginsOpened(false); this.curPlugin = {} as PluginConfig; }, search: '', @@ -176,7 +176,7 @@ export const FormItemPlugins = <T extends FieldValues>( onEdit={(name) => pluginsOb.on('edit', name)} /> <PluginEditorDrawer - mode={isView ? 'view' : 'edit'} + mode={isView ? 'view' : pluginsOb.mode} schema={toJS(pluginsOb.curPluginSchema)} opened={pluginsOb.editorOpened} onClose={pluginsOb.closeEditor} diff --git a/src/components/form-slice/FormPartBasic.tsx b/src/components/form-slice/FormPartBasic.tsx index 961ac1e24..0cd3b7178 100644 --- a/src/components/form-slice/FormPartBasic.tsx +++ b/src/components/form-slice/FormPartBasic.tsx @@ -14,7 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { PropsWithChildren, ReactNode } from 'react'; +import type { ComboboxItem } from '@mantine/core'; +import { type PropsWithChildren, type ReactNode, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -28,6 +29,31 @@ import { FormItemTextarea } from '../form/Textarea'; import { FormItemTextInput } from '../form/TextInput'; import { FormSection, type FormSectionProps } from './FormSection'; +const FormItemStatus = () => { + const { control } = useFormContext<APISIXType['Basic']>(); + const { t } = useTranslation(); + const np = useNamePrefix(); + const options = useMemo( + (): ComboboxItem[] => + APISIXCommon.Status.options.map((v) => ({ + value: String(v.value), + label: t(`form.basic.statusOption.${v.value}`), + })), + [t] + ); + return ( + <FormItemSelect + control={control} + name={np('status')} + label={t('form.basic.status')} + defaultValue={APISIXCommon.Status.options[1].value} + data={options} + from={String} + to={Number} + /> + ); +}; + export type FormPartBasicProps = Omit<FormSectionProps, 'form'> & PropsWithChildren & { before?: ReactNode; @@ -69,17 +95,7 @@ export const FormPartBasic = (props: FormPartBasicProps) => { /> )} {showLabels && <FormItemLabels name={np('labels')} control={control} />} - {showStatus && ( - <FormItemSelect - control={control} - name="status" - label={t('form.basic.status')} - defaultValue={APISIXCommon.Status.options[1].value.toString()} - data={APISIXCommon.Status.options.map((v) => v.value.toString())} - from={String} - to={Number} - /> - )} + {showStatus && <FormItemStatus />} {children} </FormSection> ); diff --git a/src/components/form-slice/FormPartUpstream/util.ts b/src/components/form-slice/FormPartUpstream/util.ts index cf49dd738..f397a7ce7 100644 --- a/src/components/form-slice/FormPartUpstream/util.ts +++ b/src/components/form-slice/FormPartUpstream/util.ts @@ -21,9 +21,13 @@ import type { APISIXType } from '@/types/schema/apisix'; import type { FormPartUpstreamType } from './schema'; -export const produceToUpstreamForm = (upstream: APISIXType['Upstream']) => - produce(upstream, (d: FormPartUpstreamType) => { - d.__checksEnabled = !!d.checks && isNotEmpty(d.checks); +export const produceToUpstreamForm = ( + upstream: Partial<APISIXType['Upstream']>, + /** default to upstream */ + base: object = upstream +) => + produce(base, (d: FormPartUpstreamType) => { + d.__checksEnabled = !!upstream.checks && isNotEmpty(upstream.checks); d.__checksPassiveEnabled = - !!d.checks?.passive && isNotEmpty(d.checks.passive); + !!upstream.checks?.passive && isNotEmpty(upstream.checks.passive); }); diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx index 33cc93c45..56357cf03 100644 --- a/src/components/form/Editor.tsx +++ b/src/components/form/Editor.tsx @@ -16,10 +16,10 @@ */ import { InputWrapper, type InputWrapperProps,Skeleton } from '@mantine/core'; import { Editor, loader, type Monaco,useMonaco } from '@monaco-editor/react'; -import type { editor } from 'monaco-editor'; +import { editor, Uri } from 'monaco-editor'; import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type FieldValues, useController, @@ -27,17 +27,15 @@ import { useFormContext, useFormState, } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { genControllerProps } from './util'; type SetupMonacoProps = { monaco: Monaco; - setError?: (err: string | null) => void; }; -const setupMonaco = (props: SetupMonacoProps) => { - const { setError, monaco } = props; - +const setupMonaco = ({ monaco }: SetupMonacoProps) => { window.MonacoEnvironment = { getWorker(_, label) { if (label === 'json') { @@ -46,19 +44,8 @@ const setupMonaco = (props: SetupMonacoProps) => { return new editorWorker(); }, }; - - monaco.editor.onDidChangeMarkers(([uri]) => { - const markers = monaco.editor.getModelMarkers({ resource: uri }); - if (markers.length === 0) { - return setError?.(null); - } - const marker = markers[0]; - setError?.(marker.message); - }); - loader.config({ monaco }); - - loader.init(); + return loader.init(); }; const options: editor.IStandaloneEditorConstructionOptions = { @@ -83,6 +70,7 @@ type FormItemEditorProps<T extends FieldValues> = InputWrapperProps & export const FormItemEditor = <T extends FieldValues>( props: FormItemEditorProps<T> ) => { + const { t } = useTranslation(); const { controllerProps, restProps } = genControllerProps(props, ''); const { customSchema, language, isLoading, ...wrapperProps } = restProps; const { setError, clearErrors } = useFormContext<{ @@ -96,10 +84,26 @@ export const FormItemEditor = <T extends FieldValues>( } = useController<T>(controllerProps); const monaco = useMonaco(); + const [internalLoading, setLoading] = useState(false); + + const showErrOnMarkers = useCallback( + (resource: Uri) => { + const markers = monaco?.editor.getModelMarkers({ resource }); + const marker = markers?.[0]; + if (!marker) return false; + setError(customErrorField, { + type: 'custom', + message: marker.message, + }); + return true; + }, + [customErrorField, monaco?.editor, setError] + ); + useEffect(() => { - if (!monaco || isLoading || !customSchema) return; + if (!monaco || !customSchema) return; + setLoading(true); monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ - ...monaco.languages.json.jsonDefaults.diagnosticsOptions, validate: true, schemas: [ { @@ -111,7 +115,14 @@ export const FormItemEditor = <T extends FieldValues>( trailingCommas: 'error', enableSchemaRequest: false, }); - }, [customSchema, monaco, isLoading]); + // when markers change, show error + monaco.editor.onDidChangeMarkers(([uri]) => { + showErrOnMarkers(uri); + }); + + setLoading(false); + }, [customSchema, monaco, showErrOnMarkers]); + return ( <InputWrapper error={ @@ -121,37 +132,62 @@ export const FormItemEditor = <T extends FieldValues>( style={{ border: '1px solid var(--mantine-color-gray-2)', borderRadius: 'var(--mantine-radius-sm)', + position: 'relative', }} id="#editor-wrapper" {...wrapperProps} > <input name={restField.name} type="hidden" /> - {isLoading && <Skeleton visible height="100%" width="100%" />} - {!isLoading && ( - <Editor - beforeMount={(monaco) => - setupMonaco({ - monaco, - setError: (err: string | null) => { - if (!err) { - return clearErrors(customErrorField); - } - setError(customErrorField, { - type: 'custom', - message: err, - }); - }, - }) - } - defaultValue={controllerProps.defaultValue} - value={value} - onChange={fOnChange} - loading={<Skeleton visible height="100%" width="100%" />} - options={{ ...options, readOnly: restField.disabled }} - defaultLanguage="json" - {...(language && { language })} + {(isLoading || internalLoading) && ( + <Skeleton + style={{ + position: 'absolute', + zIndex: 1, + top: 0, + left: 0, + }} + data-testid="editor-loading" + visible + height="100%" + width="100%" /> )} + <Editor + beforeMount={(monaco) => { + setupMonaco({ + monaco, + }); + }} + defaultValue={controllerProps.defaultValue} + value={value} + onChange={fOnChange} + loading={ + <Skeleton + data-testid="editor-loading" + visible + height="100%" + width="100%" + /> + } + options={{ ...options, readOnly: restField.disabled }} + onMount={(editor) => { + // this only check json validity, will clear error when json is valid and no markers + editor.onDidChangeModelContent(() => { + try { + const model = editor.getModel()!; + JSON.parse(model.getValue()); + clearErrors(customErrorField); + } catch { + return setError(customErrorField, { + type: 'custom', + message: t('form.json.parseError'), + }); + } + }); + }} + defaultLanguage="json" + {...(language && { language })} + /> </InputWrapper> ); }; diff --git a/src/components/form/Select.tsx b/src/components/form/Select.tsx index e7b7d2142..32943154b 100644 --- a/src/components/form/Select.tsx +++ b/src/components/form/Select.tsx @@ -23,11 +23,8 @@ import { import { genControllerProps } from './util'; -export type FormItemSelectProps< - T extends FieldValues, - R -> = UseControllerProps<T> & - SelectProps & { +export type FormItemSelectProps<T extends FieldValues, R> = UseControllerProps<T> & + Omit<SelectProps, 'value' | 'defaultValue'> & { from?: (v: R) => string; to?: (v: string) => R; }; diff --git a/src/locales/en/common.json b/src/locales/en/common.json index fc0716d36..8195d80c7 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -24,6 +24,10 @@ }, "name": "Name", "status": "Status", + "statusOption": { + "0": "Disabled", + "1": "Enabled" + }, "title": "Basic Infomation" }, "btn": { @@ -50,6 +54,9 @@ "title": "Information", "update_time": "Updated At" }, + "json": { + "parseError": "JSON format is not valid" + }, "plugins": { "addPlugin": "Add Plugin", "configId": "Plugin Config ID", diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx index c8ced1d40..debe733ea 100644 --- a/src/routes/routes/detail.$id.tsx +++ b/src/routes/routes/detail.$id.tsx @@ -32,6 +32,7 @@ import { getRouteQueryOptions } from '@/apis/hooks'; import { putRouteReq } from '@/apis/routes'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; +import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; @@ -64,7 +65,9 @@ const RouteDetailForm = (props: Props) => { useEffect(() => { if (routeData?.value && !isLoading) { - form.reset(routeData.value); + form.reset( + produceToUpstreamForm(routeData.value.upstream || {}, routeData.value) + ); } }, [routeData, form, isLoading]); diff --git a/src/styles/global.css b/src/styles/global.css index da70f8816..02fe48f3c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -8,3 +8,7 @@ width: 200px !important; left: 60px !important; } + +.monaco-editor { + padding-bottom: 200px; +} diff --git a/vite.config.ts b/vite.config.ts index ba48397e2..4368ad818 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,6 +37,13 @@ if (inDevContainer) { export default defineConfig({ base: BASE_PATH, server: { + // as an example, if you want to use the e2e server as the api server, + proxy: { + [API_PREFIX]: { + target: 'http://localhost:6174', + changeOrigin: true, + }, + }, ...(inDevContainer && { host: '0.0.0.0', port: 5173,