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 <[email protected]>
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,