This is an automated email from the ASF dual-hosted git repository.
LiteSun 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 631eadc25 fix: warn before discarding unsaved form changes (#3443)
631eadc25 is described below
commit 631eadc25dff721df77f2271e4e7319ff9f5ee14
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 14:34:51 2026 +0800
fix: warn before discarding unsaved form changes (#3443)
---
.../consumer_groups.crud-required-fields.spec.ts | 9 +-
.../regression/form.cancel-unsaved-warning.spec.ts | 10 +-
.../regression/form.unsaved-changes-guard.spec.ts | 218 +++++++++++++++++++++
e2e/tests/ssls.crud-all-fields.spec.ts | 9 +-
src/components/form/Btn.tsx | 20 ++
src/hooks/useEditCancelGuard.tsx | 67 +++----
src/hooks/useUnsavedChangesGuard.tsx | 101 ++++++++++
src/routes/consumer_groups/add.tsx | 33 ++--
src/routes/consumer_groups/detail.$id.tsx | 2 +
src/routes/consumers/add.tsx | 25 ++-
.../consumers/detail.$username/credentials/add.tsx | 36 ++--
.../detail.$username/credentials/detail.$id.tsx | 2 +
src/routes/consumers/detail.$username/index.tsx | 2 +
src/routes/global_rules/add.tsx | 35 ++--
src/routes/global_rules/detail.$id.tsx | 2 +
src/routes/plugin_configs/add.tsx | 33 ++--
src/routes/plugin_configs/detail.$id.tsx | 2 +
src/routes/protos/add.tsx | 27 ++-
src/routes/protos/detail.$id.tsx | 2 +
src/routes/routes/add.tsx | 35 ++--
src/routes/routes/detail.$id.tsx | 2 +
src/routes/secrets/add.tsx | 35 ++--
src/routes/secrets/detail.$manager.$id.tsx | 2 +
src/routes/services/add.tsx | 25 ++-
src/routes/services/detail.$id/index.tsx | 2 +
src/routes/services/detail.$id/routes/add.tsx | 4 +
.../services/detail.$id/stream_routes/add.tsx | 4 +
src/routes/ssls/add.tsx | 23 ++-
src/routes/ssls/detail.$id.tsx | 2 +
src/routes/stream_routes/add.tsx | 35 ++--
src/routes/stream_routes/detail.$id.tsx | 2 +
src/routes/upstreams/add.tsx | 22 ++-
src/routes/upstreams/detail.$id.tsx | 2 +
src/utils/form-dirty.test.ts | 204 +++++++++++++++++++
src/utils/form-dirty.ts | 109 +++++++++++
35 files changed, 960 insertions(+), 183 deletions(-)
diff --git a/e2e/tests/consumer_groups.crud-required-fields.spec.ts
b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
index 5f0330909..c3352fd5e 100644
--- a/e2e/tests/consumer_groups.crud-required-fields.spec.ts
+++ b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
@@ -117,13 +117,10 @@ test('should CRUD Consumer Group with required fields',
async ({ page }) => {
const idField = page.getByRole('textbox', { name: 'ID', exact: true });
await expect(idField).toBeDisabled();
- // Cancel without making changes. The Edit→Cancel guard now always
- // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
+ // Cancel without making changes. The Edit→Cancel guard only warns when
+ // the form actually holds unsaved edits (see
src/hooks/useEditCancelGuard.tsx),
+ // so a pristine Cancel returns straight to view mode without a modal.
await page.getByRole('button', { name: 'Cancel' }).click();
- await page
- .getByRole('dialog')
- .getByRole('button', { name: 'Discard Changes' })
- .click();
// Verify we're back in detail view
await consumerGroupsPom.isDetailPage(page);
diff --git a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
index 1aed3f329..05a7a0d08 100644
--- a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
+++ b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
@@ -100,14 +100,16 @@ test('route detail Edit → Cancel with unsaved changes
warns the user', async (
test('route detail Edit → Cancel modal is dismissable (Cancel in modal stays
in edit mode)', async ({
page,
}) => {
- // Until the underlying form lifecycle is restructured (see the comment in
- // useEditCancelGuard.tsx), the modal is shown on every Cancel click.
- // This test pins the dismiss path: backing out of the warning modal must
- // leave the user in edit mode without touching the form data.
+ // The modal is shown only when the form actually holds changes, so make
+ // one first. This test pins the dismiss path: backing out of the warning
+ // must leave the user in edit mode without touching the form data.
await uiGoto(page, '/routes/detail/$id', { id: seededRouteId });
await routesPom.isDetailPage(page);
await page.getByRole('button', { name: 'Edit' }).click();
+ await page
+ .getByLabel('URI', { exact: true })
+ .fill('/regression/edit-cancel-dismiss');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
const modal = page
diff --git a/e2e/tests/regression/form.unsaved-changes-guard.spec.ts
b/e2e/tests/regression/form.unsaved-changes-guard.spec.ts
new file mode 100644
index 000000000..74463a73f
--- /dev/null
+++ b/e2e/tests/regression/form.unsaved-changes-guard.spec.ts
@@ -0,0 +1,218 @@
+/**
+ * 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.
+ */
+
+// Regression for the dirty-form navigation guard: leaving a form with
+// unsaved edits — by sidebar link or the Cancel button — must confirm
+// before discarding. Conversely a form the user has not actually changed
+// must never interrogate them, which is the failure mode the raw
+// react-hook-form `isDirty` flag produces on add pages whose widgets
+// normalize `undefined` to empty values on mount.
+
+import { routesPom } from '@e2e/pom/routes';
+import { secretsPom } from '@e2e/pom/secrets';
+import { sslsPom } from '@e2e/pom/ssls';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect, type Page } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const unsavedModal = (page: Page) =>
+ page.getByRole('dialog').filter({ hasText: /unsaved/i });
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('leaving a dirty add form via the sidebar warns, and backing out keeps
the input', async ({
+ page,
+}) => {
+ const name = randomId('reg-guard');
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.locator('input[name="name"]').fill(name);
+
+ await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+ const modal = unsavedModal(page);
+ await expect(modal).toBeVisible({ timeout: 5000 });
+
+ await modal.getByRole('button', { name: 'Cancel', exact: true }).click();
+ await expect(modal).toBeHidden();
+
+ await routesPom.isAddPage(page);
+ await expect(page.locator('input[name="name"]')).toHaveValue(name);
+});
+
+test('confirming the warning discards the edits and completes the navigation',
async ({
+ page,
+}) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.locator('input[name="name"]').fill(randomId('reg-guard'));
+ await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+ const modal = unsavedModal(page);
+ await expect(modal).toBeVisible({ timeout: 5000 });
+ await modal.getByRole('button', { name: /discard/i }).click();
+
+ await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('a successful submit navigates without a warning', async ({ page }) => {
+ // After a successful POST the form is still dirty relative to its
+ // defaults, so without `bypass()` the add page's own success redirect is
+ // blocked by the guard it just installed.
+ const name = randomId('reg-guard-submit');
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.locator('input[name="name"]').fill(name);
+ await page.getByLabel('URI', { exact: true }).fill(`/reg-guard/${name}`);
+
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ name: `${name}-upstream`,
+ nodes: [
+ { host: 'guard-a.local', port: 80, weight: 100 },
+ { host: 'guard-b.local', port: 80, weight: 100 },
+ ],
+ });
+
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+
+ await expect(unsavedModal(page)).toBeHidden();
+ await routesPom.isDetailPage(page);
+});
+
+test('a pristine add page navigates away without interrogating the user',
async ({
+ page,
+}) => {
+ // ssls/add is one of five add pages whose widgets normalize `undefined`
+ // to empty values on mount (certs: [], keys: []), so react-hook-form
+ // reports it dirty with zero user input. The guard must not.
+ await sslsPom.toAdd(page);
+ await sslsPom.isAddPage(page);
+
+ await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+ await expect(unsavedModal(page)).toBeHidden();
+ await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('a pristine nanoid add page (secrets) navigates away without
interrogating the user', async ({
+ page,
+}) => {
+ // secrets/add seeds defaultValues.id with nanoid(); the id must be stable
+ // across renders or the pristine form reads dirty and the guard wrongly
+ // interrogates the user.
+ await secretsPom.toAdd(page);
+ await secretsPom.isAddPage(page);
+
+ await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+ await expect(unsavedModal(page)).toBeHidden();
+ await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('Cancel on a pristine add page returns to the list without a warning',
async ({
+ page,
+}) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+ await expect(unsavedModal(page)).toBeHidden();
+ await routesPom.isIndexPage(page);
+});
+
+test('Cancel on a dirty add page warns first', async ({ page }) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+
+ await page.locator('input[name="name"]').fill(randomId('reg-guard-cancel'));
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+ const modal = unsavedModal(page);
+ await expect(modal).toBeVisible({ timeout: 5000 });
+ await modal.getByRole('button', { name: /discard/i }).click();
+
+ await routesPom.isIndexPage(page);
+});
+
+test('Edit then Cancel with nothing changed does not interrogate the user',
async ({
+ page,
+}) => {
+ const name = randomId('reg-guard-clean');
+ const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+ `/routes/${name}`,
+ {
+ name,
+ uri: `/reg-guard-clean/${name}`,
+ upstream: { type: 'roundrobin', nodes: { 'guard.local:80': 1 } },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/routes/detail/$id', { id });
+ await routesPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+ await expect(unsavedModal(page)).toBeHidden();
+ await expect(page.getByLabel('URI', { exact: true })).toBeDisabled();
+});
+
+test('leaving a dirty detail form via the sidebar warns', async ({ page }) => {
+ const name = randomId('reg-guard-dirty');
+ const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+ `/routes/${name}`,
+ {
+ name,
+ uri: `/reg-guard-dirty/${name}`,
+ upstream: { type: 'roundrobin', nodes: { 'guard.local:80': 1 } },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/routes/detail/$id', { id });
+ await routesPom.isDetailPage(page);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ // The route detail page renders two "Description" fields (the route's own
+ // and the nested upstream's), so target the route-level one by name.
+ await page.locator('textarea[name="desc"]').fill('changed by the guard
spec');
+
+ await page.getByRole('link', { name: 'Services', exact: true }).click();
+ await expect(unsavedModal(page)).toBeVisible({ timeout: 5000 });
+});
diff --git a/e2e/tests/ssls.crud-all-fields.spec.ts
b/e2e/tests/ssls.crud-all-fields.spec.ts
index 1273d6d5d..83d95413b 100644
--- a/e2e/tests/ssls.crud-all-fields.spec.ts
+++ b/e2e/tests/ssls.crud-all-fields.spec.ts
@@ -149,13 +149,10 @@ test('should CRUD SSL with all fields', async ({ page })
=> {
const cert1Field = page.getByRole('textbox', { name: 'Certificate 1' });
await expect(cert1Field).toBeEnabled();
- // Cancel without making changes. The Edit→Cancel guard now always
- // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
+ // Cancel without making changes. The Edit→Cancel guard only warns when
+ // the form actually holds unsaved edits (see
src/hooks/useEditCancelGuard.tsx),
+ // so a pristine Cancel returns straight to view mode without a modal.
await page.getByRole('button', { name: 'Cancel' }).click();
- await page
- .getByRole('dialog')
- .getByRole('button', { name: 'Discard Changes' })
- .click();
// Return to list page
await sslsPom.getSSLNavBtn(page).click();
diff --git a/src/components/form/Btn.tsx b/src/components/form/Btn.tsx
index ac25ab41b..cbb8e341d 100644
--- a/src/components/form/Btn.tsx
+++ b/src/components/form/Btn.tsx
@@ -19,7 +19,11 @@ import {
type ButtonProps,
type PolymorphicComponentProps,
} from '@mantine/core';
+import type { LinkProps } from '@tanstack/react-router';
import { useFormContext, useFormState } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+import { RouteLinkBtn } from '@/components/Btn';
export const FormSubmitBtn = (
props: PolymorphicComponentProps<'button', ButtonProps>
@@ -28,3 +32,19 @@ export const FormSubmitBtn = (
const { isSubmitting } = useFormState(form);
return <Button type="submit" loading={isSubmitting} {...props} />;
};
+
+export type FormCancelBtnProps = Pick<LinkProps, 'to' | 'params'>;
+
+/**
+ * Abandon a form and go back to its list page. Rendered as a router link
+ * so a dirty form routes it through the same navigation guard as any
+ * other navigation — no separate confirmation code path.
+ */
+export const FormCancelBtn = ({ to, params }: FormCancelBtnProps) => {
+ const { t } = useTranslation();
+ return (
+ <RouteLinkBtn variant="outline" to={to} params={params}>
+ {t('form.btn.cancel')}
+ </RouteLinkBtn>
+ );
+};
diff --git a/src/hooks/useEditCancelGuard.tsx b/src/hooks/useEditCancelGuard.tsx
index 1bed93c38..4dfb96701 100644
--- a/src/hooks/useEditCancelGuard.tsx
+++ b/src/hooks/useEditCancelGuard.tsx
@@ -14,56 +14,43 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { Text } from '@mantine/core';
-import { modals } from '@mantine/modals';
import { useCallback } from 'react';
import type { FieldValues, UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
+import { confirmDiscardChanges } from '@/hooks/useUnsavedChangesGuard';
+import { isFormDirty } from '@/utils/form-dirty';
+
/**
- * Guard the Edit-mode → Cancel handler with a confirmation modal so a
- * misclick can't silently throw away in-flight changes.
- *
- * Why the modal always shows (even on a clean form):
- *
- * Every Edit page in this app uses the pattern
- *
- * useForm({ disabled: readOnly, defaultValues: ... })
- * useEffect(() => form.reset(producedValues), [data, form])
- *
- * which has two failure modes for any "is the form actually dirty?" check:
- *
- * 1. Toggling `disabled` from true→false on Edit re-renders every
- * controlled input and can fire spurious change events that flip
- * react-hook-form's `isDirty` to true with no user input.
- * 2. The `form.reset(...)` inside a useEffect runs whenever the backing
- * query refetches (tab focus, window focus, mutation success), which
- * wipes `isDirty` back to false mid-session — so a legitimately
- * edited form can transiently appear clean.
- *
- * Both make `isDirty` unreliable as a "skip the modal" signal. Until the
- * underlying form lifecycle is restructured (tracked as a follow-up
- * cleanup), we always show the modal. One extra click is a far better
- * failure mode than silently losing an edit.
+ * Guard the Edit-mode → Cancel handler so a misclick cannot throw away
+ * in-flight changes — but only when there are changes to throw away.
+ *
+ * This hook used to open the modal unconditionally, on the theory that
+ * `isDirty` could not be trusted under the `disabled`-toggling edit
+ * architecture: that toggling `disabled` fires spurious change events, and
+ * that the reset-on-refetch effect wipes dirtiness mid-session. Neither
+ * reproduces. Entering edit mode leaves the form clean on all twelve
+ * detail pages, and an ordinary refetch returns the same `data` reference
+ * (react-query structural sharing), so the reset effect never re-runs.
+ *
+ * `isFormDirty` rather than react-hook-form's `isDirty` because the raw
+ * flag reports pristine add pages as dirty; the two hooks share one
+ * definition of "changed".
*/
export const useEditCancelGuard = <T extends FieldValues>(
form: UseFormReturn<T>,
onCancel: () => void
) => {
const { t } = useTranslation();
- return useCallback(() => {
- modals.openConfirmModal({
- centered: true,
- title: t('info.unsaved.title'),
- children: <Text size="sm">{t('info.unsaved.content')}</Text>,
- labels: {
- confirm: t('info.unsaved.confirm'),
- cancel: t('form.btn.cancel'),
- },
- onConfirm: () => {
- form.reset();
- onCancel();
- },
- });
+ return useCallback(async () => {
+ const discard = () => {
+ form.reset();
+ onCancel();
+ };
+ if (!isFormDirty(form.formState.defaultValues, form.getValues())) {
+ discard();
+ return;
+ }
+ if (await confirmDiscardChanges(t)) discard();
}, [form, onCancel, t]);
};
diff --git a/src/hooks/useUnsavedChangesGuard.tsx
b/src/hooks/useUnsavedChangesGuard.tsx
new file mode 100644
index 000000000..e7b5daf3b
--- /dev/null
+++ b/src/hooks/useUnsavedChangesGuard.tsx
@@ -0,0 +1,101 @@
+/**
+ * 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 { Text } from '@mantine/core';
+import { modals } from '@mantine/modals';
+import { useBlocker } from '@tanstack/react-router';
+import { useCallback, useRef } from 'react';
+import type { FieldValues, UseFormReturn } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+import { isFormDirty } from '@/utils/form-dirty';
+
+type Translate = ReturnType<typeof useTranslation>['t'];
+
+/**
+ * Ask the user whether to throw away unsaved edits.
+ *
+ * Resolves true when they confirm. Escape and outside-clicks resolve
+ * false via onClose; Promise.resolve is idempotent, so the onConfirm path
+ * still wins when both fire.
+ */
+export const confirmDiscardChanges = (t: Translate) =>
+ new Promise<boolean>((resolve) => {
+ modals.openConfirmModal({
+ centered: true,
+ title: t('info.unsaved.title'),
+ children: <Text size="sm">{t('info.unsaved.content')}</Text>,
+ labels: {
+ confirm: t('info.unsaved.confirm'),
+ cancel: t('form.btn.cancel'),
+ },
+ onConfirm: () => resolve(true),
+ onCancel: () => resolve(false),
+ onClose: () => resolve(false),
+ });
+ });
+
+type UseUnsavedChangesGuardOptions = {
+ /** Skip the guard entirely — e.g. a detail page in read-only mode. */
+ disabled?: boolean;
+};
+
+/**
+ * Block router navigations and tab close/reload while the form holds
+ * unsaved work.
+ *
+ * Dirtiness comes from `isFormDirty`, not react-hook-form's `isDirty`:
+ * the raw flag reports 5 of 11 add pages as dirty before the user types
+ * anything, because their widgets normalize `undefined` to empty values
+ * on mount.
+ *
+ * The returned `bypass()` must be called before a navigation the form
+ * itself initiates after a successful submit — at that point the form is
+ * still dirty relative to its defaults, so the guard would otherwise
+ * block the page's own success redirect.
+ */
+export const useUnsavedChangesGuard = <T extends FieldValues>(
+ form: UseFormReturn<T>,
+ options: UseUnsavedChangesGuardOptions = {}
+) => {
+ const { t } = useTranslation();
+ const bypassRef = useRef(false);
+
+ const hasUnsavedChanges = useCallback(() => {
+ if (bypassRef.current) return false;
+ return isFormDirty(form.formState.defaultValues, form.getValues());
+ }, [form]);
+
+ // Stable identity so useBlocker (which keys its effect on shouldBlockFn)
+ // subscribes once rather than re-subscribing every render — which would
+ // also re-run isFormDirty's structuredClone on each render.
+ const shouldBlockFn = useCallback(async () => {
+ if (!hasUnsavedChanges()) return false;
+ return !(await confirmDiscardChanges(t));
+ }, [hasUnsavedChanges, t]);
+
+ useBlocker({
+ disabled: options.disabled,
+ enableBeforeUnload: hasUnsavedChanges,
+ shouldBlockFn,
+ });
+
+ const bypass = useCallback(() => {
+ bypassRef.current = true;
+ }, []);
+
+ return { bypass };
+};
diff --git a/src/routes/consumer_groups/add.tsx
b/src/routes/consumer_groups/add.tsx
index ed216f38d..57ff3b0fb 100644
--- a/src/routes/consumer_groups/add.tsx
+++ b/src/routes/consumer_groups/add.tsx
@@ -15,26 +15,41 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { nanoid } from 'nanoid';
+import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putConsumerGroupReq } from '@/apis/consumer_groups';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartPluginConfig } from
'@/components/form-slice/FormPartPluginConfig';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
const ConsumerGroupAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const [id] = useState(() => nanoid());
+
+ const form = useForm({
+ resolver: zodResolver(APISIX.ConsumerGroupPut),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ defaultValues: {
+ id,
+ },
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
const putConsumerGroup = useMutation({
mutationFn: (d: APISIXType['ConsumerGroupPut']) =>
@@ -44,6 +59,7 @@ const ConsumerGroupAddForm = () => {
message: t('info.add.success', { name: t('consumerGroups.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/consumer_groups/detail/$id',
params: { id: response.data.value.id },
@@ -51,16 +67,6 @@ const ConsumerGroupAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.ConsumerGroupPut),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- defaultValues: {
- id: nanoid(),
- },
- });
-
return (
<FormProvider {...form}>
<form
@@ -70,7 +76,10 @@ const ConsumerGroupAddForm = () => {
>
<FormSectionGeneral />
<FormPartPluginConfig basicProps={{ showName: false }} />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/consumer_groups" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/consumer_groups/detail.$id.tsx
b/src/routes/consumer_groups/detail.$id.tsx
index 8e9895622..ece24f68f 100644
--- a/src/routes/consumer_groups/detail.$id.tsx
+++ b/src/routes/consumer_groups/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_CONSUMER_GROUPS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -80,6 +81,7 @@ const ConsumerGroupDetailForm = (props: Props) => {
form.reset(data.value);
}, [form, data.value]);
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (!data) return <Skeleton height={200} />;
diff --git a/src/routes/consumers/add.tsx b/src/routes/consumers/add.tsx
index 6d3a33c64..dd305df8e 100644
--- a/src/routes/consumers/add.tsx
+++ b/src/routes/consumers/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,11 +23,12 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putConsumerReq } from '@/apis/consumers';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartConsumer } from '@/components/form-slice/FormPartConsumer';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -34,6 +36,14 @@ const ConsumerAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(APISIX.ConsumerPut),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const putConsumer = useMutation({
mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d),
async onSuccess(_, res) {
@@ -41,6 +51,7 @@ const ConsumerAddForm = () => {
message: t('info.add.success', { name: t('consumers.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/consumers/detail/$username',
params: { username: res.username },
@@ -48,13 +59,6 @@ const ConsumerAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.ConsumerPut),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- });
-
return (
<FormProvider {...form}>
<form
@@ -63,7 +67,10 @@ const ConsumerAddForm = () => {
)}
>
<FormPartConsumer readOnlyUsername={false} />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/consumers" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/consumers/detail.$username/credentials/add.tsx
b/src/routes/consumers/detail.$username/credentials/add.tsx
index dd92a9dc6..b40acf448 100644
--- a/src/routes/consumers/detail.$username/credentials/add.tsx
+++ b/src/routes/consumers/detail.$username/credentials/add.tsx
@@ -15,20 +15,23 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useParams, useRouter } from '@tanstack/react-router';
import { nanoid } from 'nanoid';
+import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putCredentialReq } from '@/apis/credentials';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartCredential } from
'@/components/form-slice/FormPartCredential';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -38,6 +41,18 @@ const CredentialAddForm = () => {
const { username } = useParams({
from: '/consumers/detail/$username/credentials/add',
});
+ const [id] = useState(() => nanoid());
+
+ const form = useForm({
+ resolver: zodResolver(APISIX.CredentialPut),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ defaultValues: {
+ id,
+ },
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
const putCredential = useMutation({
mutationFn: (d: APISIXType['CredentialPut']) =>
@@ -49,6 +64,7 @@ const CredentialAddForm = () => {
}),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/consumers/detail/$username/credentials/detail/$id',
params: { username, id: res.id },
@@ -56,22 +72,18 @@ const CredentialAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.CredentialPut),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- defaultValues: {
- id: nanoid(),
- },
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putCredential.mutateAsync(d))}>
<FormSectionGeneral />
<FormPartCredential />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn
+ to="/consumers/detail/$username/credentials"
+ params={{ username }}
+ />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
index 2684840bc..89aafafd8 100644
--- a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
+++ b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_CREDENTIALS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -87,6 +88,7 @@ const CredentialDetailForm = (props: CredentialFormProps) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (isLoading) {
diff --git a/src/routes/consumers/detail.$username/index.tsx
b/src/routes/consumers/detail.$username/index.tsx
index b0ae4bea7..44751eab4 100644
--- a/src/routes/consumers/detail.$username/index.tsx
+++ b/src/routes/consumers/detail.$username/index.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_CONSUMERS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -81,6 +82,7 @@ const ConsumerDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (isLoading) {
diff --git a/src/routes/global_rules/add.tsx b/src/routes/global_rules/add.tsx
index 80a787341..b8f2d2a1a 100644
--- a/src/routes/global_rules/add.tsx
+++ b/src/routes/global_rules/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import {
@@ -22,22 +23,37 @@ import {
useRouter as useReactRouter,
} from '@tanstack/react-router';
import { nanoid } from 'nanoid';
+import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putGlobalRuleReq } from '@/apis/global_rules';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartGlobalRules } from
'@/components/form-slice/FormPartGlobalRules';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import type { APISIXType } from '@/types/schema/apisix';
import { APISIX } from '@/types/schema/apisix';
const GlobalRuleAddForm = () => {
const { t } = useTranslation();
const router = useReactRouter();
+ const [id] = useState(() => nanoid());
+
+ const form = useForm({
+ resolver: zodResolver(APISIX.GlobalRulePut),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ defaultValues: {
+ plugins: {},
+ id,
+ },
+ mode: 'onChange',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
const putGlobalRule = useMutation({
mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d),
@@ -47,6 +63,7 @@ const GlobalRuleAddForm = () => {
message: t('info.add.success', { name: t('globalRules.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/global_rules/detail/$id',
params: { id: res.data.value.id },
@@ -54,23 +71,15 @@ const GlobalRuleAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.GlobalRulePut),
- shouldUnregister: true,
- shouldFocusError: true,
- defaultValues: {
- plugins: {},
- id: nanoid(),
- },
- mode: 'onChange',
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putGlobalRule.mutateAsync(d))}>
<FormSectionGeneral />
<FormPartGlobalRules />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/global_rules" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/global_rules/detail.$id.tsx
b/src/routes/global_rules/detail.$id.tsx
index 29e012242..5c6e65f2c 100644
--- a/src/routes/global_rules/detail.$id.tsx
+++ b/src/routes/global_rules/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_GLOBAL_RULES } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
type Props = {
@@ -78,6 +79,7 @@ const GlobalRuleDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
return (
diff --git a/src/routes/plugin_configs/add.tsx
b/src/routes/plugin_configs/add.tsx
index ee9ef397b..3ff25a695 100644
--- a/src/routes/plugin_configs/add.tsx
+++ b/src/routes/plugin_configs/add.tsx
@@ -15,26 +15,41 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { nanoid } from 'nanoid';
+import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putPluginConfigReq } from '@/apis/plugin_configs';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartPluginConfig } from
'@/components/form-slice/FormPartPluginConfig';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
const PluginConfigAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const [id] = useState(() => nanoid());
+
+ const form = useForm({
+ resolver: zodResolver(APISIX.PluginConfigPut),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ defaultValues: {
+ id,
+ },
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
const putPluginConfig = useMutation({
mutationFn: (d: APISIXType['PluginConfigPut']) =>
@@ -44,6 +59,7 @@ const PluginConfigAddForm = () => {
message: t('info.add.success', { name: t('pluginConfigs.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/plugin_configs/detail/$id',
params: { id: response.data.value.id },
@@ -51,16 +67,6 @@ const PluginConfigAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.PluginConfigPut),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- defaultValues: {
- id: nanoid(),
- },
- });
-
return (
<FormProvider {...form}>
<form
@@ -70,7 +76,10 @@ const PluginConfigAddForm = () => {
>
<FormSectionGeneral />
<FormPartPluginConfig />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/plugin_configs" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/plugin_configs/detail.$id.tsx
b/src/routes/plugin_configs/detail.$id.tsx
index 6342c2899..4cf8304a6 100644
--- a/src/routes/plugin_configs/detail.$id.tsx
+++ b/src/routes/plugin_configs/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_PLUGIN_CONFIGS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -82,6 +83,7 @@ const PluginConfigDetailForm = (props: Props) => {
form.reset(initialValue);
}, [form, initialValue]);
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (!data) return <Skeleton height={200} />;
diff --git a/src/routes/protos/add.tsx b/src/routes/protos/add.tsx
index 798dac75f..9e5bd2054 100644
--- a/src/routes/protos/add.tsx
+++ b/src/routes/protos/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import {
@@ -25,10 +26,11 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { postProtoReq } from '@/apis/protos';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartProto } from '@/components/form-slice/FormPartProto';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import type { APISIXType } from '@/types/schema/apisix';
import { APISIXProtos } from '@/types/schema/apisix/protos';
@@ -40,6 +42,15 @@ const ProtoAddForm = () => {
const { t } = useTranslation();
const router = useReactRouter();
+ const form = useForm({
+ resolver: zodResolver(APISIXProtos.ProtoPost),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ defaultValues,
+ mode: 'onChange',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postProto = useMutation({
mutationFn: (d: APISIXType['ProtoPost']) => postProtoReq(req, d),
async onSuccess() {
@@ -47,23 +58,19 @@ const ProtoAddForm = () => {
message: t('info.add.success', { name: t('protos.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({ to: '/protos' });
},
});
- const form = useForm({
- resolver: zodResolver(APISIXProtos.ProtoPost),
- shouldUnregister: true,
- shouldFocusError: true,
- defaultValues,
- mode: 'onChange',
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => postProto.mutateAsync(d))}>
<FormPartProto />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/protos" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/protos/detail.$id.tsx b/src/routes/protos/detail.$id.tsx
index c71de41d2..672593445 100644
--- a/src/routes/protos/detail.$id.tsx
+++ b/src/routes/protos/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_PROTOS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -82,6 +83,7 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }:
ProtoFormProps) => {
}
}, [protoData, form]);
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (isLoading) {
diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx
index 47d37a26b..c67d742b4 100644
--- a/src/routes/routes/add.tsx
+++ b/src/routes/routes/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
@@ -22,7 +23,11 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { postRouteReq } from '@/apis/routes';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import {
+ FormCancelBtn,
+ type FormCancelBtnProps,
+ FormSubmitBtn,
+} from '@/components/form/Btn';
import { FormPartRoute } from '@/components/form-slice/FormPartRoute';
import {
RoutePostSchema,
@@ -33,18 +38,29 @@ import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUp
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import type { APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
type Props = {
navigate: (res: APISIXType['RespRouteDetail']) => Promise<void>;
defaultValues?: Partial<RoutePostType>;
+ cancelLink: FormCancelBtnProps;
};
export const RouteAddForm = (props: Props) => {
- const { navigate, defaultValues } = props;
+ const { navigate, defaultValues, cancelLink } = props;
const { t } = useTranslation();
+ const form = useForm({
+ resolver: zodResolver(RoutePostSchema),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ defaultValues,
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postRoute = useMutation({
mutationFn: (d: RoutePostType) =>
postRouteReq(
@@ -59,23 +75,19 @@ export const RouteAddForm = (props: Props) => {
message: t('info.add.success', { name: t('routes.singular') }),
color: 'green',
});
+ bypass();
await navigate(res);
},
});
- const form = useForm({
- resolver: zodResolver(RoutePostSchema),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- defaultValues,
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => postRoute.mutateAsync(d))}>
<FormPartRoute />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn {...cancelLink} />
+ </Group>
</form>
</FormProvider>
);
@@ -95,6 +107,7 @@ function RouteComponent() {
params: { id: res.data.value.id },
})
}
+ cancelLink={{ to: '/routes' }}
/>
</FormTOCBox>
</>
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index baf0916df..43e98bd03 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -52,6 +52,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_ROUTES } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -113,6 +114,7 @@ const RouteDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
return (
diff --git a/src/routes/secrets/add.tsx b/src/routes/secrets/add.tsx
index ee62710b1..1c86de958 100644
--- a/src/routes/secrets/add.tsx
+++ b/src/routes/secrets/add.tsx
@@ -15,27 +15,43 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { nanoid } from 'nanoid';
+import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { putSecretReq } from '@/apis/secrets';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartSecret } from '@/components/form-slice/FormPartSecret';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import PageHeader from '@/components/page/PageHeader';
import { queryClient } from '@/config/global';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
const SecretAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const [id] = useState(() => nanoid());
+
+ const form = useForm({
+ resolver: zodResolver(APISIX.Secret),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ defaultValues: {
+ id,
+ manager: APISIX.Secret.options[0].shape.manager.value,
+ },
+ mode: 'all',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
const putSecret = useMutation({
mutationFn: (d: APISIXType['Secret']) =>
@@ -47,29 +63,22 @@ const SecretAddForm = () => {
});
// Invalidate secrets list query to refetch fresh data
await queryClient.invalidateQueries({ queryKey: ['secrets'] });
+ bypass();
await router.navigate({
to: '/secrets',
});
},
});
- const form = useForm({
- resolver: zodResolver(APISIX.Secret),
- shouldUnregister: true,
- shouldFocusError: true,
- defaultValues: {
- id: nanoid(),
- manager: APISIX.Secret.options[0].shape.manager.value,
- },
- mode: 'all',
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putSecret.mutateAsync(d))}>
<FormSectionGeneral />
<FormPartSecret />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/secrets" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/secrets/detail.$manager.$id.tsx
b/src/routes/secrets/detail.$manager.$id.tsx
index 69b4ead46..d5771d30f 100644
--- a/src/routes/secrets/detail.$manager.$id.tsx
+++ b/src/routes/secrets/detail.$manager.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_SECRETS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -88,6 +89,7 @@ const SecretDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (isLoading) {
diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx
index 28498edad..a00e91957 100644
--- a/src/routes/services/add.tsx
+++ b/src/routes/services/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,13 +23,14 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { postServiceReq, type ServicePostType } from '@/apis/services';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartService } from '@/components/form-slice/FormPartService';
import { ServicePostSchema } from
'@/components/form-slice/FormPartService/schema';
import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
import { pipeProduce } from '@/utils/producer';
@@ -36,6 +38,14 @@ const ServiceAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(ServicePostSchema),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postService = useMutation({
mutationFn: (d: ServicePostType) =>
postServiceReq(
@@ -47,6 +57,7 @@ const ServiceAddForm = () => {
message: t('info.add.success', { name: t('services.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/services/detail/$id',
params: { id: res.data.value.id },
@@ -54,18 +65,14 @@ const ServiceAddForm = () => {
},
});
- const form = useForm({
- resolver: zodResolver(ServicePostSchema),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d as
ServicePostType))}>
<FormPartService />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/services" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/services/detail.$id/index.tsx
b/src/routes/services/detail.$id/index.tsx
index a0a5a70f9..b6f879f90 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -43,6 +43,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_SERVICES } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
import { pipeProduce } from '@/utils/producer';
@@ -98,6 +99,7 @@ const ServiceDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
return (
diff --git a/src/routes/services/detail.$id/routes/add.tsx
b/src/routes/services/detail.$id/routes/add.tsx
index 7b383a2a8..45aa7b9e3 100644
--- a/src/routes/services/detail.$id/routes/add.tsx
+++ b/src/routes/services/detail.$id/routes/add.tsx
@@ -44,6 +44,10 @@ function RouteComponent() {
defaultValues={{
service_id: id,
}}
+ cancelLink={{
+ to: '/services/detail/$id/routes',
+ params: { id },
+ }}
/>
</FormTOCBox>
</CommonFormContext.Provider>
diff --git a/src/routes/services/detail.$id/stream_routes/add.tsx
b/src/routes/services/detail.$id/stream_routes/add.tsx
index b062eb421..2c088992d 100644
--- a/src/routes/services/detail.$id/stream_routes/add.tsx
+++ b/src/routes/services/detail.$id/stream_routes/add.tsx
@@ -47,6 +47,10 @@ function RouteComponent() {
defaultValues={{
service_id: id,
}}
+ cancelLink={{
+ to: '/services/detail/$id/stream_routes',
+ params: { id },
+ }}
/>
</FormTOCBox>
</CommonFormContext.Provider>
diff --git a/src/routes/ssls/add.tsx b/src/routes/ssls/add.tsx
index 7cbc1c0b0..f72b66d71 100644
--- a/src/routes/ssls/add.tsx
+++ b/src/routes/ssls/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,7 +23,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { postSSLReq } from '@/apis/ssls';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartSSL } from '@/components/form-slice/FormPartSSL';
import {
SSLPostSchema,
@@ -32,11 +33,19 @@ import { FormTOCBox } from
'@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { queryClient } from '@/config/global';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { pipeProduce } from '@/utils/producer';
const SSLAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(SSLPostSchema),
+ shouldUnregister: true,
+ mode: 'all',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postSSL = useMutation({
mutationFn: (d: SSLPostType) => postSSLReq(req, pipeProduce()(d)),
async onSuccess() {
@@ -46,23 +55,21 @@ const SSLAddForm = () => {
});
// Invalidate SSLs list query to refetch fresh data
await queryClient.invalidateQueries({ queryKey: ['ssls'] });
+ bypass();
await router.navigate({
to: '/ssls',
});
},
});
- const form = useForm({
- resolver: zodResolver(SSLPostSchema),
- shouldUnregister: true,
- mode: 'all',
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => postSSL.mutateAsync(d))}>
<FormPartSSL />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/ssls" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx
index 2f4d996aa..c7a1b7e98 100644
--- a/src/routes/ssls/detail.$id.tsx
+++ b/src/routes/ssls/detail.$id.tsx
@@ -44,6 +44,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_SSLS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { pipeProduce } from '@/utils/producer';
type Props = {
@@ -93,6 +94,7 @@ const SSLDetailForm = (props: Props & { id: string }) => {
}
}, [sslData, form, isLoading]);
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
if (isLoading) {
diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx
index 6364ad03a..05cabbcad 100644
--- a/src/routes/stream_routes/add.tsx
+++ b/src/routes/stream_routes/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
@@ -22,7 +23,11 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { postStreamRouteReq } from '@/apis/stream_routes';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import {
+ FormCancelBtn,
+ type FormCancelBtnProps,
+ FormSubmitBtn,
+} from '@/components/form/Btn';
import { FormPartStreamRoute } from
'@/components/form-slice/FormPartStreamRoute';
import {
StreamRoutePostSchema,
@@ -33,17 +38,28 @@ import { FormTOCBox } from
'@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { StreamRoutesErrorComponent } from
'@/components/page-slice/stream_routes/ErrorComponent';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import type { APISIXType } from '@/types/schema/apisix';
type Props = {
navigate: (res: APISIXType['RespStreamRouteDetail']) => Promise<void>;
defaultValues?: Partial<StreamRoutePostType>;
+ cancelLink: FormCancelBtnProps;
};
export const StreamRouteAddForm = (props: Props) => {
- const { navigate, defaultValues } = props;
+ const { navigate, defaultValues, cancelLink } = props;
const { t } = useTranslation();
+ const form = useForm({
+ resolver: zodResolver(StreamRoutePostSchema),
+ shouldUnregister: true,
+ shouldFocusError: true,
+ mode: 'all',
+ defaultValues,
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postStreamRoute = useMutation({
mutationFn: (d: StreamRoutePostType) =>
postStreamRouteReq(req, produceStreamRoute(d)),
@@ -52,23 +68,19 @@ export const StreamRouteAddForm = (props: Props) => {
message: t('info.add.success', { name: t('streamRoutes.singular') }),
color: 'green',
});
+ bypass();
await navigate(res);
},
});
- const form = useForm({
- resolver: zodResolver(StreamRoutePostSchema),
- shouldUnregister: true,
- shouldFocusError: true,
- mode: 'all',
- defaultValues,
- });
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) =>
postStreamRoute.mutateAsync(d))}>
<FormPartStreamRoute />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn {...cancelLink} />
+ </Group>
</form>
</FormProvider>
);
@@ -90,6 +102,7 @@ function RouteComponent() {
params: { id: res.data.value.id },
})
}
+ cancelLink={{ to: '/stream_routes' }}
/>
</FormTOCBox>
</>
diff --git a/src/routes/stream_routes/detail.$id.tsx
b/src/routes/stream_routes/detail.$id.tsx
index 3b66a7618..16d2e5f4a 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -42,6 +42,7 @@ import { StreamRoutesErrorComponent } from
'@/components/page-slice/stream_route
import { API_STREAM_ROUTES } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { APISIX, type APISIXType } from '@/types/schema/apisix';
type Props = {
@@ -90,6 +91,7 @@ const StreamRouteDetailForm = (props: Props) => {
},
});
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
return (
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index b03105720..7024630bd 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -23,13 +24,14 @@ import { useTranslation } from 'react-i18next';
import type { z } from 'zod';
import { postUpstreamReq } from '@/apis/upstreams';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
import { FormPartUpstreamSchema } from
'@/components/form-slice/FormPartUpstream/schema';
import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { pipeProduce } from '@/utils/producer';
const PostUpstreamSchema = FormPartUpstreamSchema.omit({
@@ -41,6 +43,13 @@ type PostUpstreamType = z.infer<typeof PostUpstreamSchema>;
const UpstreamAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(PostUpstreamSchema),
+ shouldUnregister: true,
+ mode: 'all',
+ });
+ const { bypass } = useUnsavedChangesGuard(form);
+
const postUpstream = useMutation({
mutationFn: (d: PostUpstreamType) => postUpstreamReq(req,
pipeProduce(produceRmEmptyUpstreamFields)(d) as PostUpstreamType),
async onSuccess(data) {
@@ -48,17 +57,13 @@ const UpstreamAddForm = () => {
message: t('info.add.success', { name: t('upstreams.singular') }),
color: 'green',
});
+ bypass();
await router.navigate({
to: '/upstreams/detail/$id',
params: { id: data.data.value.id },
});
},
});
- const form = useForm({
- resolver: zodResolver(PostUpstreamSchema),
- shouldUnregister: true,
- mode: 'all',
- });
return (
<FormProvider {...form}>
@@ -68,7 +73,10 @@ const UpstreamAddForm = () => {
)}
>
<FormPartUpstream />
- <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <Group>
+ <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+ <FormCancelBtn to="/upstreams" />
+ </Group>
</form>
</FormProvider>
);
diff --git a/src/routes/upstreams/detail.$id.tsx
b/src/routes/upstreams/detail.$id.tsx
index b7ba2eb05..f4921d249 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -48,6 +48,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_UPSTREAMS } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import type { APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -111,6 +112,7 @@ const UpstreamDetailForm = (
}
}, [upstreamData, form]);
+ useUnsavedChangesGuard(form, { disabled: readOnly });
const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
return (
diff --git a/src/utils/form-dirty.test.ts b/src/utils/form-dirty.test.ts
new file mode 100644
index 000000000..25e48c183
--- /dev/null
+++ b/src/utils/form-dirty.test.ts
@@ -0,0 +1,204 @@
+/**
+ * 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 { describe, expect, it } from 'vitest';
+
+import { objToUpstreamNodes } from
'@/components/form-slice/FormPartUpstream/nodes-conversion';
+
+import { isFormDirty, normalizeForCompare } from './form-dirty';
+
+// Fixtures are real snapshots captured from the running app: on these add
+// pages the form widgets normalize `undefined` into empty values on mount,
+// so react-hook-form reports `isDirty === true` before the user types
+// anything. A navigation guard driven by the raw flag would interrogate
+// the user on 5 of 11 add pages for no reason.
+
+describe('isFormDirty — pristine add pages must compare clean', () => {
+ it('ignores empty arrays introduced on mount (ssls/add)', () => {
+ const defaults = {};
+ const values = { certs: [], keys: [] };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('ignores empty timestamp strings introduced on mount (secrets/add)', ()
=> {
+ const defaults = { id: 'abc', manager: 'vault' };
+ const values = {
+ create_time: '',
+ update_time: '',
+ id: 'abc',
+ manager: 'vault',
+ };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('ignores an empty plugins object introduced on mount (global_rules/add)',
() => {
+ const defaults = { id: 'abc' };
+ const values = {
+ create_time: '',
+ update_time: '',
+ id: 'abc',
+ plugins: {},
+ };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+});
+
+describe('isFormDirty — real edits must compare dirty', () => {
+ it('detects a typed field value', () => {
+ expect(isFormDirty({ desc: 'before' }, { desc: 'after' })).toBe(true);
+ });
+
+ it('detects a plugin added with an empty config', () => {
+ expect(
+ isFormDirty({ plugins: {} }, { plugins: { prometheus: {} } })
+ ).toBe(true);
+ });
+
+ it('detects a plugin config edited to an empty member', () => {
+ expect(
+ isFormDirty(
+ { plugins: { 'key-auth': { key: 'k' } } },
+ { plugins: { 'key-auth': { key: '' } } }
+ )
+ ).toBe(true);
+ });
+
+ it('detects a non-empty field cleared by the user', () => {
+ expect(isFormDirty({ desc: 'before' }, { desc: '' })).toBe(true);
+ });
+});
+
+describe('isFormDirty — fields that must never affect the verdict', () => {
+ it('ignores __-prefixed UI flags', () => {
+ expect(
+ isFormDirty(
+ { uri: '/a', __checksEnabled: false },
+ { uri: '/a', __checksEnabled: true }
+ )
+ ).toBe(false);
+ });
+
+ it('ignores create_time and update_time', () => {
+ expect(
+ isFormDirty(
+ { uri: '/a', create_time: 1, update_time: 1 },
+ { uri: '/a', create_time: 2, update_time: 2 }
+ )
+ ).toBe(false);
+ });
+
+ it('treats a missing value object as empty rather than throwing', () => {
+ expect(() => isFormDirty(undefined, { uri: '/a' })).not.toThrow();
+ expect(isFormDirty(undefined, { uri: '/a' })).toBe(true);
+ expect(isFormDirty(undefined, {})).toBe(false);
+ });
+});
+
+describe('normalizeForCompare', () => {
+ it('does not mutate its input', () => {
+ const input = { desc: '', plugins: { a: {} } };
+ normalizeForCompare(input);
+ expect(input).toEqual({ desc: '', plugins: { a: {} } });
+ });
+});
+
+// The Admin API stores upstream `nodes` as an object map (`{ "host:port":
+// weight }`); the nodes widget normalizes that into an array on mount
+// (`[{ host, port, weight, priority }]`). Fixtures below build the array
+// side with the real converter (`objToUpstreamNodes`) so they match its
+// output exactly rather than guessing field names/order.
+describe('isFormDirty — upstream nodes object-map vs array form', () => {
+ it('does not flag a pristine edit page nested under upstream.nodes', () => {
+ const nodes = { 'h1.local:80': 1 };
+ const defaults = { upstream: { type: 'roundrobin', nodes } };
+ const values = {
+ upstream: { type: 'roundrobin', nodes: objToUpstreamNodes(nodes) },
+ };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('does not flag a pristine edit page with top-level nodes
(upstreams/add)', () => {
+ const nodes = { 'h1.local:80': 1 };
+ const defaults = { nodes };
+ const values = { nodes: objToUpstreamNodes(nodes) };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('does not flag a bracketed IPv6 node key', () => {
+ const nodes = { '[::1]:80': 1 };
+ const defaults = { upstream: { nodes } };
+ const values = { upstream: { nodes: objToUpstreamNodes(nodes) } };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('does not flag a port-less node key', () => {
+ const nodes = { 'h1.local': 1 };
+ const defaults = { upstream: { nodes } };
+ const values = { upstream: { nodes: objToUpstreamNodes(nodes) } };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+
+ it('still flags a genuinely edited node weight', () => {
+ const defaults = {
+ upstream: { nodes: { 'h1.local:80': 1, 'h2.local:80': 2 } },
+ };
+ const values = {
+ upstream: {
+ nodes: objToUpstreamNodes({ 'h1.local:80': 5, 'h2.local:80': 2 }),
+ },
+ };
+ expect(isFormDirty(defaults, values)).toBe(true);
+ });
+
+ it('still flags a node added by the user', () => {
+ const defaults = { upstream: { nodes: { 'h1.local:80': 1 } } };
+ const values = {
+ upstream: {
+ nodes: objToUpstreamNodes({
+ 'h1.local:80': 1,
+ 'h2.local:80': 2,
+ }),
+ },
+ };
+ expect(isFormDirty(defaults, values)).toBe(true);
+ });
+
+ it('still flags a node removed by the user', () => {
+ const defaults = {
+ upstream: { nodes: { 'h1.local:80': 1, 'h2.local:80': 2 } },
+ };
+ const values = {
+ upstream: { nodes: objToUpstreamNodes({ 'h1.local:80': 1 }) },
+ };
+ expect(isFormDirty(defaults, values)).toBe(true);
+ });
+
+ it('still flags a node host edited by the user', () => {
+ const defaults = { upstream: { nodes: { 'h1.local:80': 1 } } };
+ const values = {
+ upstream: { nodes: objToUpstreamNodes({ 'h2.local:80': 1 }) },
+ };
+ expect(isFormDirty(defaults, values)).toBe(true);
+ });
+
+ it('ignores node order in the array form', () => {
+ const a = { host: 'h1.local', port: 80, weight: 1 };
+ const b = { host: 'h2.local', port: 80, weight: 2 };
+ const defaults = { upstream: { nodes: [a, b] } };
+ const values = { upstream: { nodes: [b, a] } };
+ expect(isFormDirty(defaults, values)).toBe(false);
+ });
+});
diff --git a/src/utils/form-dirty.ts b/src/utils/form-dirty.ts
new file mode 100644
index 000000000..37341daac
--- /dev/null
+++ b/src/utils/form-dirty.ts
@@ -0,0 +1,109 @@
+/**
+ * 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 { equals } from 'rambdax';
+
+import { objToUpstreamNodes } from
'@/components/form-slice/FormPartUpstream/nodes-conversion';
+import type { APISIXType } from '@/types/schema/apisix';
+
+import { deepCleanEmptyKeys, rmDoubleUnderscoreKeys } from './producer';
+
+/** Server-managed timestamps: never a user edit. */
+const IGNORED_KEYS = ['create_time', 'update_time'];
+
+/**
+ * Canonicalize upstream `nodes` so the object-map form the Admin API stores
+ * (`{ "host:port": weight }`) compares equal to the array form the nodes
+ * widget produces on mount (`[{ host, port, weight, priority }]`).
+ *
+ * Without this, every edit page whose upstream stores object-form nodes reads
+ * dirty the instant the widget mounts, with zero user input:
+ * `formState.defaultValues` keeps the object form (it is seeded straight from
+ * the producer, which never rewrites nodes) while `getValues()` returns the
+ * array form the widget normalized to. `priority` defaults to 0 on both sides
+ * — the widget invents 0, the object form cannot express it at all.
+ */
+const canonicalizeNodes = (nodes: unknown): unknown => {
+ const arr = Array.isArray(nodes)
+ ? nodes
+ : nodes && typeof nodes === 'object'
+ ? objToUpstreamNodes(nodes as APISIXType['UpstreamNodeObj'])
+ : null;
+ if (!arr) return nodes;
+ return arr
+ .map((node) =>
+ node && typeof node === 'object'
+ ? {
+ ...(node as Record<string, unknown>),
+ priority: (node as Record<string, unknown>).priority ?? 0,
+ }
+ : node
+ )
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
+};
+
+/** Rewrite every `nodes` field in place to its canonical, comparable form. */
+const canonicalizeNodesDeep = (value: unknown): void => {
+ if (!value || typeof value !== 'object') return;
+ if (Array.isArray(value)) {
+ value.forEach(canonicalizeNodesDeep);
+ return;
+ }
+ const obj = value as Record<string, unknown>;
+ if ('nodes' in obj) obj.nodes = canonicalizeNodes(obj.nodes);
+ Object.values(obj).forEach(canonicalizeNodesDeep);
+};
+
+/**
+ * Reduce a form value to the shape that decides whether the user has
+ * unsaved work.
+ *
+ * Add pages seed `useForm` with little or nothing, and the widgets
+ * normalize `undefined` into `""` / `[]` / `{}` as they mount. Comparing
+ * raw values — or trusting react-hook-form's `isDirty`, which is computed
+ * from the same raw values — reports 5 of 11 add pages as dirty before the
+ * user has typed anything (measured: ssls, secrets, global_rules,
+ * plugin_configs, consumer_groups).
+ *
+ * The `plugins` subtree is deliberately NOT cleaned: an added plugin whose
+ * config is still empty is a real edit, and cleaning would erase the only
+ * evidence of it. Normalizing `undefined` to `{}` keeps the pristine
+ * global_rules/add case (`plugins: {}` on one side, absent on the other)
+ * from registering as a change.
+ *
+ * `deepCleanEmptyKeys` (fast-clean) already leaves `false` and `0` alone by
+ * default — only `undefined`, `''`, `NaN`, `{}` and `[]` are stripped — so
+ * no extra option is needed to keep booleans like `__checksEnabled` (itself
+ * removed a line below by `rmDoubleUnderscoreKeys`, but any other boolean
+ * field must survive the clean untouched).
+ */
+export const normalizeForCompare = (
+ value: unknown
+): Record<string, unknown> => {
+ if (!value || typeof value !== 'object') return { plugins: {} };
+ const copy = structuredClone(value) as Record<string, unknown>;
+ const plugins = copy.plugins;
+ delete copy.plugins;
+ IGNORED_KEYS.forEach((key) => delete copy[key]);
+ canonicalizeNodesDeep(copy);
+ rmDoubleUnderscoreKeys(copy);
+ deepCleanEmptyKeys(copy);
+ return { ...copy, plugins: plugins ?? {} };
+};
+
+/** True when the form holds work the user has not saved. */
+export const isFormDirty = (defaults: unknown, values: unknown): boolean =>
+ !equals(normalizeForCompare(defaults), normalizeForCompare(values));