diff --git a/e2e/tests/consumer_groups.list.spec.ts b/e2e/tests/consumer_groups.list.spec.ts index a9dd217b83..b2ff734453 100644 --- a/e2e/tests/consumer_groups.list.spec.ts +++ b/e2e/tests/consumer_groups.list.spec.ts @@ -47,6 +47,7 @@ const consumerGroups: APISIXType['ConsumerGroupPut'][] = Array.from( test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllConsumerGroups(e2eReq); await Promise.all( diff --git a/e2e/tests/consumers.list.spec.ts b/e2e/tests/consumers.list.spec.ts index 43ff5ce9b5..685a770861 100644 --- a/e2e/tests/consumers.list.spec.ts +++ b/e2e/tests/consumers.list.spec.ts @@ -49,6 +49,7 @@ const consumers: APISIXType['ConsumerPut'][] = Array.from({ length: 11 }, (_, i) test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllConsumers(e2eReq); await Promise.all(consumers.map((d) => putConsumerReq(e2eReq, d))); diff --git a/e2e/tests/hot-path.upstream-service-route.spec.ts b/e2e/tests/hot-path.upstream-service-route.spec.ts index 60521f3bcd..811c870cf4 100644 --- a/e2e/tests/hot-path.upstream-service-route.spec.ts +++ b/e2e/tests/hot-path.upstream-service-route.spec.ts @@ -61,6 +61,7 @@ test('can create upstream -> service -> route', async ({ page }) => { scheme: 'https', nodes: [{ host: 'httpbin.org', port: 443 }], }; + await test.step('create upstream', async () => { // Navigate to the upstream list page await upstreamsPom.toIndex(page); @@ -158,6 +159,7 @@ test('can create upstream -> service -> route', async ({ page }) => { }, }, } satisfies Partial; + await test.step('create service', async () => { // upstream id should be set expect(service.upstream_id).not.toBeUndefined(); @@ -275,6 +277,7 @@ test('can create upstream -> service -> route', async ({ page }) => { }, }, }; + await test.step('create route', async () => { // service id should be set expect(route.service_id).not.toBeUndefined(); diff --git a/e2e/tests/plugin_configs.list.spec.ts b/e2e/tests/plugin_configs.list.spec.ts index f4a67a396b..2806b042d4 100644 --- a/e2e/tests/plugin_configs.list.spec.ts +++ b/e2e/tests/plugin_configs.list.spec.ts @@ -67,6 +67,7 @@ const pluginConfigs: APISIXType['PluginConfigPut'][] = Array.from( test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllPluginConfigs(e2eReq); await Promise.all(pluginConfigs.map((d) => putPluginConfigReq(e2eReq, d))); diff --git a/e2e/tests/protos.list.spec.ts b/e2e/tests/protos.list.spec.ts index 2f17897e08..cf9222e34b 100644 --- a/e2e/tests/protos.list.spec.ts +++ b/e2e/tests/protos.list.spec.ts @@ -55,6 +55,7 @@ message TestMessage${i + 1} { test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { // Delete all existing protos const existingProtos = await e2eReq diff --git a/e2e/tests/routes.list.spec.ts b/e2e/tests/routes.list.spec.ts index b6f171d920..7a999cd12d 100644 --- a/e2e/tests/routes.list.spec.ts +++ b/e2e/tests/routes.list.spec.ts @@ -63,6 +63,7 @@ const routes: APISIXType['Route'][] = Array.from({ length: 11 }, (_, i) => ({ test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllRoutes(e2eReq); await Promise.all(routes.map((d) => putRouteReq(e2eReq, d))); diff --git a/e2e/tests/services.crud-required-fields.spec.ts b/e2e/tests/services.crud-required-fields.spec.ts index f0f17b3eec..ede2ac2c6b 100644 --- a/e2e/tests/services.crud-required-fields.spec.ts +++ b/e2e/tests/services.crud-required-fields.spec.ts @@ -41,6 +41,7 @@ test('should CRUD service with required fields', async ({ page }) => { await servicesPom.getAddServiceBtn(page).click(); await servicesPom.isAddPage(page); + await test.step('submit with required fields', async () => { await uiFillServiceRequiredFields(page, { name: serviceName, diff --git a/e2e/tests/services.list.spec.ts b/e2e/tests/services.list.spec.ts index ceb3972951..ef45cba464 100644 --- a/e2e/tests/services.list.spec.ts +++ b/e2e/tests/services.list.spec.ts @@ -55,6 +55,7 @@ const services: APISIXType['Service'][] = Array.from({ length: 11 }, (_, i) => ( test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllServices(e2eReq); await Promise.all( diff --git a/e2e/tests/stream_routes.list.spec.ts b/e2e/tests/stream_routes.list.spec.ts index 5363b6fe53..28f9c5cc28 100644 --- a/e2e/tests/stream_routes.list.spec.ts +++ b/e2e/tests/stream_routes.list.spec.ts @@ -58,6 +58,7 @@ const streamRoutes: APISIXType['StreamRoute'][] = Array.from( test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllStreamRoutes(e2eReq); await Promise.all( diff --git a/e2e/tests/upstreams.list.spec.ts b/e2e/tests/upstreams.list.spec.ts index 4119da66d1..99a252ef0c 100644 --- a/e2e/tests/upstreams.list.spec.ts +++ b/e2e/tests/upstreams.list.spec.ts @@ -60,6 +60,7 @@ const upstreams: APISIXType['Upstream'][] = Array.from( test.describe('page and page_size should work correctly', () => { test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { await deleteAllUpstreams(e2eReq); await Promise.all(upstreams.map((d) => putUpstreamReq(e2eReq, d))); diff --git a/src/apis/routes.ts b/src/apis/routes.ts index cb99f71776..d9b4ffcb7b 100644 --- a/src/apis/routes.ts +++ b/src/apis/routes.ts @@ -48,6 +48,12 @@ export const putRouteReq = (req: AxiosInstance, data: APISIXType['Route']) => { export const postRouteReq = (req: AxiosInstance, data: RoutePostType) => req.post(API_ROUTES, data); +// Raw JSON POST for JSON View - accepts APISIX format directly +export const postRouteRawReq = ( + req: AxiosInstance, + data: Omit +) => req.post(API_ROUTES, data); + export const deleteAllRoutes = async (req: AxiosInstance) => { const totalRes = await getRouteListReq(req, { page: 1, diff --git a/src/components/form-slice/FormPartBasic.tsx b/src/components/form-slice/FormPartBasic.tsx index 0cd3b7178f..75f31f27aa 100644 --- a/src/components/form-slice/FormPartBasic.tsx +++ b/src/components/form-slice/FormPartBasic.tsx @@ -61,6 +61,7 @@ export type FormPartBasicProps = Omit & showName?: boolean; showDesc?: boolean; showLabels?: boolean; + namePlaceholder?: string; }; export const FormPartBasic = (props: FormPartBasicProps) => { @@ -71,6 +72,7 @@ export const FormPartBasic = (props: FormPartBasicProps) => { showName = true, showDesc = true, showLabels = true, + namePlaceholder, ...restProps } = props; const { control } = useFormContext(); @@ -84,6 +86,8 @@ export const FormPartBasic = (props: FormPartBasicProps) => { )} @@ -91,10 +95,17 @@ export const FormPartBasic = (props: FormPartBasicProps) => { )} - {showLabels && } + {showLabels && ( + + )} {showStatus && } {children} diff --git a/src/components/form-slice/FormPartRoute/index.tsx b/src/components/form-slice/FormPartRoute/index.tsx index bb483eba0f..3fc497374f 100644 --- a/src/components/form-slice/FormPartRoute/index.tsx +++ b/src/components/form-slice/FormPartRoute/index.tsx @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Divider, InputWrapper } from '@mantine/core'; +import { Divider, InputWrapper, Text } from '@mantine/core'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -39,11 +39,12 @@ const FormPartBasicWithPriority = () => { const { t } = useTranslation(); const { control } = useFormContext(); return ( - + @@ -55,55 +56,75 @@ const FormSectionMatchRules = () => { const { control } = useFormContext(); return ( + + {t('form.routes.matchRulesDesc')} + v.value)} searchValue="" /> - + ); @@ -113,7 +134,10 @@ export const FormSectionUpstream = () => { const { t } = useTranslation(); const { control } = useFormContext(); return ( - + + + {t('form.upstreams.requiredDesc')} + @@ -150,6 +174,9 @@ export const FormSectionService = () => { legend={t('form.routes.service')} disabled={readOnlyFields.includes('service_id')} > + + {t('form.routes.serviceDesc')} + { export const FormPartService = () => { return ( <> - + diff --git a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx index 0bb13c13ff..94a2cdea9a 100644 --- a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx +++ b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx @@ -15,8 +15,14 @@ * limitations under the License. */ import { EditableProTable, type ProColumns } from '@ant-design/pro-components'; -import { Button, InputWrapper, type InputWrapperProps } from '@mantine/core'; -import { useClickOutside } from '@mantine/hooks'; +import { + ActionIcon, + Button, + InputWrapper, + type InputWrapperProps, + Tooltip, +} from '@mantine/core'; +import { useDebouncedCallback } from '@mantine/hooks'; import { toJS } from 'mobx'; import { useLocalObservable } from 'mobx-react-lite'; import { nanoid } from 'nanoid'; @@ -33,6 +39,8 @@ import type { ZodObject, ZodRawShape } from 'zod'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { zGetDefault } from '@/utils/zod'; +import IconAdd from '~icons/material-symbols/add'; +import IconDelete from '~icons/material-symbols/delete'; import { genControllerProps } from '../../form/util'; @@ -134,13 +142,25 @@ export const FormItemNodes = ( title: t('form.upstreams.nodes.host.title'), dataIndex: 'host', valueType: 'text', - formItemProps: genProps('host'), + formItemProps: { + ...genProps('host'), + }, + fieldProps: { + placeholder: 'httpbin.org', + }, + width: '40%', }, { title: t('form.upstreams.nodes.port.title'), dataIndex: 'port', valueType: 'digit', formItemProps: genProps('port'), + fieldProps: { + placeholder: '80', + min: 1, + max: 65535, + }, + width: '15%', render: (_, entity) => { return entity.port.toString(); }, @@ -150,6 +170,11 @@ export const FormItemNodes = ( dataIndex: 'weight', valueType: 'digit', formItemProps: genProps('weight'), + fieldProps: { + placeholder: '1', + min: 0, + }, + width: '15%', render: (_, entity) => { return entity.weight.toString(); }, @@ -159,14 +184,18 @@ export const FormItemNodes = ( dataIndex: 'priority', valueType: 'digit', formItemProps: genProps('priority'), + fieldProps: { + placeholder: '0', + }, + width: '15%', render: (_, entity) => { - return entity.priority?.toString() || '-'; + return entity.priority?.toString() || '0'; }, }, { - title: t('form.upstreams.nodes.action.title'), + title: '', valueType: 'option', - width: 100, + width: 50, hidden: disabled, render: () => null, }, @@ -203,11 +232,12 @@ export const FormItemNodes = ( ob.setDisabled(disabled); }, [disabled, ob]); - const ref = useClickOutside(() => { - const vals = parseToUpstreamNodes(toJS(ob.values)); + // Sync form data with debounce to avoid excessive updates + const syncToForm = useDebouncedCallback((dataSource: DataSource[]) => { + const vals = parseToUpstreamNodes(dataSource); fOnChange?.(vals); restProps.onChange?.(vals); - }, ['mouseup', 'touchend', 'mousedown', 'touchstart']); + }, 100); return ( ( label={label} required={required} withAsterisk={withAsterisk} - ref={ref} > @@ -232,18 +261,23 @@ export const FormItemNodes = ( editableKeys: ob.editableKeys, onValuesChange(_, dataSource) { ob.setValues(dataSource); + syncToForm(dataSource); }, actionRender: (row) => { return [ - , + + { + ob.remove(row.id); + syncToForm(toJS(ob.values)); + }} + > + + + , ]; }, }} @@ -251,12 +285,15 @@ export const FormItemNodes = ( + + + + + + )} + + ); +}; diff --git a/src/components/page/JSONEditDrawer.tsx b/src/components/page/JSONEditDrawer.tsx new file mode 100644 index 0000000000..f826b1b6b0 --- /dev/null +++ b/src/components/page/JSONEditDrawer.tsx @@ -0,0 +1,163 @@ +/** + * 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 { Button, Drawer, Group, Skeleton, Stack, Text } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { FormItemEditor } from '@/components/form/Editor'; +import { validateJSONSyntax } from '@/utils/json-transformer'; + +export type JSONEditDrawerProps = { + opened: boolean; + onClose: () => void; + title: string; + /** Query options to fetch the resource data */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryOptions: UseQueryOptions; + /** Function to save the edited JSON */ + onSave: (data: Record) => Promise; + /** Callback after successful save */ + onSuccess?: () => void; +}; + +export const JSONEditDrawer = (props: JSONEditDrawerProps) => { + const { opened, onClose, title, queryOptions, onSave, onSuccess } = props; + const { t } = useTranslation(); + + const [jsonValue, setJsonValue] = useState('{}'); + const [validationError, setValidationError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const { data, isLoading, refetch } = useQuery({ + ...queryOptions, + enabled: opened, + }); + + const form = useForm<{ json: string }>({ + defaultValues: { json: '{}' }, + }); + + const { setValue, watch } = form; + const watchedJson = watch('json'); + + // Update form when data loads + useEffect(() => { + if (data?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = data.value; + const formatted = JSON.stringify(displayData, null, 2); + setJsonValue(formatted); + setValue('json', formatted); + } + }, [data, isLoading, setValue]); + + // Sync watched value to local state + useEffect(() => { + setJsonValue(watchedJson); + }, [watchedJson]); + + // Validate JSON on change + useEffect(() => { + const { isValid, error } = validateJSONSyntax(jsonValue); + if (!isValid) { + setValidationError(error || t('form.view.invalidJSON')); + } else { + setValidationError(null); + } + }, [jsonValue, t]); + + const handleSave = async () => { + const { isValid } = validateJSONSyntax(jsonValue); + if (!isValid) return; + + try { + setIsSaving(true); + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToSave } = parsed; + await onSave(dataToSave); + notifications.show({ + message: t('info.edit.success', { name: title }), + color: 'green', + }); + await refetch(); + await onSuccess?.(); + onClose(); + } catch (error) { + notifications.show({ + message: error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + setValidationError(null); + onClose(); + }; + + return ( + + {isLoading ? ( + + ) : ( + + + {validationError && ( + + {validationError} + + )} + + + + + + + + + + )} + + ); +}; diff --git a/src/components/page/JSONEditorView.tsx b/src/components/page/JSONEditorView.tsx new file mode 100644 index 0000000000..d0465a2529 --- /dev/null +++ b/src/components/page/JSONEditorView.tsx @@ -0,0 +1,186 @@ +/** + * 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 { Button, Group, Stack, Text } from '@mantine/core'; +import { useEffect, useRef, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type { ZodSchema } from 'zod'; + +import { FormItemEditor } from '@/components/form/Editor'; +import { validateJSONSyntax } from '@/utils/json-transformer'; + +export type JSONEditorViewProps = { + /** The JSON string to display in the editor */ + value: string; + /** Whether the editor is in read-only mode */ + readOnly: boolean; + /** Callback when save button is clicked (unified state's save returns boolean) */ + onSave: () => Promise; + /** Callback when cancel button is clicked */ + onCancel?: () => void; + /** Callback when JSON value changes (for unified state integration) */ + onChange?: (jsonString: string) => void; + /** Optional Zod schema for validation */ + schema?: ZodSchema; + /** Whether save operation is in progress */ + isSaving?: boolean; +}; + +/** + * JSONEditorView Component + * + * Provides a Monaco-based JSON editor for viewing and editing resource configurations. + * Supports read-only mode, JSON syntax validation, and optional schema validation. + */ +export const JSONEditorView = (props: JSONEditorViewProps) => { + const { + value, + readOnly, + onSave, + onCancel, + onChange, + schema, + isSaving = false, + } = props; + const { t } = useTranslation(); + + const [validationError, setValidationError] = useState(null); + // Track if update came from external prop to prevent circular updates + const isExternalUpdate = useRef(false); + + const form = useForm<{ json: string }>({ + defaultValues: { json: value }, + disabled: readOnly, + }); + + const { handleSubmit, setValue, watch } = form; + const jsonValue = watch('json'); + + // Update form when external value changes + useEffect(() => { + isExternalUpdate.current = true; + setValue('json', value); + }, [value, setValue]); + + // Report changes to unified state (only for internal edits) + useEffect(() => { + // Skip if this was triggered by external value update + if (isExternalUpdate.current) { + isExternalUpdate.current = false; + return; + } + if (!readOnly && onChange && jsonValue !== value) { + onChange(jsonValue); + } + }, [jsonValue, onChange, readOnly, value]); + + // Validate JSON on change + useEffect(() => { + if (readOnly) { + setValidationError(null); + return; + } + + const { isValid, error } = validateJSONSyntax(jsonValue); + if (!isValid) { + setValidationError(error || t('form.view.invalidJSON')); + return; + } + + // If syntax is valid, check schema if provided + if (schema) { + try { + const parsed = JSON.parse(jsonValue); + schema.parse(parsed); + setValidationError(null); + } catch (schemaError) { + if (schemaError instanceof Error) { + setValidationError( + `${t('form.view.schemaValidationFailed')}: ${schemaError.message}` + ); + } else { + setValidationError(t('form.view.schemaValidationFailed')); + } + } + } else { + setValidationError(null); + } + }, [jsonValue, readOnly, schema, t]); + + const handleSaveClick = handleSubmit(async ({ json }) => { + // Final validation before save + const { isValid } = validateJSONSyntax(json); + if (!isValid) { + return; + } + + if (schema) { + try { + const parsed = JSON.parse(json); + schema.parse(parsed); + } catch { + return; // Validation error already shown in state + } + } + + // Call unified state's save (json is already in unified state via onChange) + await onSave(); + }); + + return ( + +
+ + {/* Validation Error Display */} + {validationError && !readOnly && ( + + {validationError} + + )} + + {/* Monaco JSON Editor */} + + + {/* Action Buttons */} + {!readOnly && ( + + + + + )} + +
+
+ ); +}; diff --git a/src/components/page/PageHeader.tsx b/src/components/page/PageHeader.tsx index dac453d881..ab3c0a82de 100644 --- a/src/components/page/PageHeader.tsx +++ b/src/components/page/PageHeader.tsx @@ -26,7 +26,7 @@ type PageHeaderProps = { const PageHeader: FC = (props) => { const { title, desc, extra } = props; return ( - + {title} diff --git a/src/components/page/PreviewJSONModal.tsx b/src/components/page/PreviewJSONModal.tsx new file mode 100644 index 0000000000..af3218cd09 --- /dev/null +++ b/src/components/page/PreviewJSONModal.tsx @@ -0,0 +1,80 @@ +/** + * 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 { Box, Button, CopyButton, Group, Modal, Skeleton, Stack, Tooltip } from '@mantine/core'; +import { Editor } from '@monaco-editor/react'; +import { useTranslation } from 'react-i18next'; + +import IconCheck from '~icons/material-symbols/check'; +import IconCopy from '~icons/material-symbols/content-copy'; + +export type PreviewJSONModalProps = { + opened: boolean; + onClose: () => void; + json: string; + title?: string; +}; + +export const PreviewJSONModal = ({ + opened, + onClose, + json, + title, +}: PreviewJSONModalProps) => { + const { t } = useTranslation(); + + return ( + + + + } + /> + + + + {({ copied, copy }) => ( + + + + )} + + + + + + ); +}; diff --git a/src/components/page/RouteList.tsx b/src/components/page/RouteList.tsx new file mode 100644 index 0000000000..74991ddd0a --- /dev/null +++ b/src/components/page/RouteList.tsx @@ -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 type { ProColumns } from '@ant-design/pro-components'; +import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, Group, TextInput } from '@mantine/core'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useRouteList } from '@/apis/hooks'; +import type { WithServiceIdFilter } from '@/apis/routes'; +import { ToAddPageBtn } from '@/components/page/ToAddPageBtn'; +import { AntdConfigProvider } from '@/config/antdConfigProvider'; +import { useTableSearch } from '@/hooks/useTableSearch'; +import type { APISIXType } from '@/types/schema/apisix'; +import type { ListPageKeys } from '@/utils/useTablePagination'; +import IconSearch from '~icons/material-symbols/search'; + +export type RouteListProps = { + routeKey: Extract; + defaultParams?: Partial; + ActionMenu: (props: { + record: APISIXType['RespRouteItem']; + refetch: () => void; + }) => React.ReactNode; + AddButton?: React.ReactNode; +}; + +export const RouteList = (props: RouteListProps) => { + const { routeKey, ActionMenu, defaultParams, AddButton } = props; + const { data, isLoading, refetch, pagination, setParams } = useRouteList( + routeKey, + defaultParams + ); + const { t } = useTranslation(); + const { searchValue, handleChange, handleClear } = useTableSearch(setParams); + + const columns = useMemo[]>(() => { + return [ + { + dataIndex: ['value', 'id'], + title: 'ID', + key: 'id', + valueType: 'text', + }, + { + dataIndex: ['value', 'name'], + title: t('form.basic.name'), + key: 'name', + valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'uri'], + title: 'URI', + key: 'uri', + valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'methods'], + title: t('table.methods'), + key: 'methods', + render: (_, record) => { + const methods = record.value.methods; + if (!methods || methods.length === 0) { + return *; + } + return ( + + {methods.slice(0, 3).map((m) => ( + {m} + ))} + {methods.length > 3 && ( + +{methods.length - 3} + )} + + ); + }, + }, + { + dataIndex: ['value', 'hosts'], + title: t('table.hosts'), + key: 'hosts', + render: (_, record) => { + const hosts = record.value.hosts; + if (!hosts || hosts.length === 0) return '-'; + if (hosts.length === 1) return hosts[0]; + return `${hosts[0]} +${hosts.length - 1}`; + }, + }, + { + dataIndex: ['value', 'status'], + title: t('form.basic.status'), + key: 'status', + valueEnum: { + 1: { text: t('table.enabled'), status: 'Success' }, + 0: { text: t('table.disabled'), status: 'Error' }, + }, + }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, + }, + { + title: t('table.actions'), + valueType: 'option', + key: 'option', + width: 80, + render: (_, record) => , + }, + ]; + }, [t, ActionMenu, refetch]); + + return ( + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => handleChange(e.target.value)} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: AddButton ?? ( + + ), + }, + ], + }, + }} + /> + + ); +}; diff --git a/src/components/page/SafeTabSwitch.tsx b/src/components/page/SafeTabSwitch.tsx new file mode 100644 index 0000000000..0a9d78e0d8 --- /dev/null +++ b/src/components/page/SafeTabSwitch.tsx @@ -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. + */ + +import { Button, Group, Modal, Stack, Text } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import type { ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface SafeTabSwitchProps { + /** Whether there are unsaved changes */ + isDirty: boolean; + /** Current active tab value */ + activeTab: string; + /** Callback when tab change is requested */ + onTabChange: (tab: string) => void; + /** Optional: Callback to save before switching (returns true if save succeeded) */ + onSave?: () => Promise; + /** Optional: Callback when user confirms discarding changes */ + onDiscard?: () => void; + /** Whether save is in progress */ + isSaving?: boolean; + /** Children (should contain Tabs component) */ + children: ReactNode; +} + +/** + * SafeTabSwitch Component + * + * Wraps tab navigation to intercept changes when there are unsaved edits. + * Shows a confirmation modal with options to: + * - Save and Switch + * - Discard and Switch + * - Cancel + * + * @example + * ```tsx + * + * ... + * + * ``` + */ +export const SafeTabSwitch = (props: SafeTabSwitchProps) => { + const { + isDirty, + activeTab, + onTabChange, + onSave, + onDiscard, + isSaving = false, + children, + } = props; + + const { t } = useTranslation(); + const [ + warningOpened, + { open: openWarning, close: closeWarning }, + ] = useDisclosure(false); + const [pendingTab, setPendingTab] = useState(null); + const [isSavingLocal, setIsSavingLocal] = useState(false); + + /** + * Handle tab change request + * If dirty, show warning modal instead of switching immediately + */ + const handleTabChangeRequest = useCallback( + (newTab: string) => { + if (newTab === activeTab) return; + + if (isDirty) { + setPendingTab(newTab); + openWarning(); + } else { + onTabChange(newTab); + } + }, + [activeTab, isDirty, onTabChange, openWarning] + ); + + /** + * Handle "Discard and Switch" action + */ + const handleDiscard = useCallback(() => { + if (pendingTab) { + onDiscard?.(); + onTabChange(pendingTab); + setPendingTab(null); + } + closeWarning(); + }, [pendingTab, onDiscard, onTabChange, closeWarning]); + + /** + * Handle "Save and Switch" action + */ + const handleSaveAndSwitch = useCallback(async () => { + if (!onSave || !pendingTab) return; + + setIsSavingLocal(true); + try { + const success = await onSave(); + if (success) { + onTabChange(pendingTab); + setPendingTab(null); + closeWarning(); + } + // If save failed, keep modal open so user can try again or discard + } finally { + setIsSavingLocal(false); + } + }, [onSave, pendingTab, onTabChange, closeWarning]); + + /** + * Handle "Cancel" action + */ + const handleCancel = useCallback(() => { + setPendingTab(null); + closeWarning(); + }, [closeWarning]); + + const isAnySaving = isSaving || isSavingLocal; + + return ( + <> + {/* Wrap children and intercept tab changes */} +
{ + // Intercept clicks on tab buttons + const target = e.target as HTMLElement; + const tabButton = target.closest('[role="tab"]'); + + if (tabButton) { + const tabValue = tabButton.getAttribute('data-value'); + if (tabValue && tabValue !== activeTab) { + e.preventDefault(); + e.stopPropagation(); + handleTabChangeRequest(tabValue); + } + } + }} + > + {children} +
+ + {/* Warning Modal */} + + + + {t('form.view.unsavedChanges')} + + + + + + + + {onSave && ( + + )} + + + + + ); +}; + +/** + * Context for SafeTabSwitch to allow child components to request tab changes + */ +export interface SafeTabSwitchContextValue { + requestTabChange: (tab: string) => void; + activeTab: string; + isDirty: boolean; +} diff --git a/src/components/page/StreamRouteList.tsx b/src/components/page/StreamRouteList.tsx new file mode 100644 index 0000000000..8a597a557a --- /dev/null +++ b/src/components/page/StreamRouteList.tsx @@ -0,0 +1,164 @@ +/** + * 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 { ProColumns } from '@ant-design/pro-components'; +import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, TextInput } from '@mantine/core'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useStreamRouteList } from '@/apis/hooks'; +import type { WithServiceIdFilter } from '@/apis/routes'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; +import { AntdConfigProvider } from '@/config/antdConfigProvider'; +import { useTableSearch } from '@/hooks/useTableSearch'; +import type { APISIXType } from '@/types/schema/apisix'; +import type { ListPageKeys } from '@/utils/useTablePagination'; +import IconSearch from '~icons/material-symbols/search'; + +export type StreamRouteListProps = { + routeKey: Extract< + ListPageKeys, + '/stream_routes/' | '/services/detail/$id/stream_routes/' + >; + ActionMenu: (props: { + record: APISIXType['RespStreamRouteItem']; + refetch: () => void; + }) => React.ReactNode; + defaultParams?: Partial; + AddButton?: React.ReactNode; +}; + +export const StreamRouteList = (props: StreamRouteListProps) => { + const { routeKey, ActionMenu, defaultParams, AddButton } = props; + const { data, isLoading, refetch, pagination, setParams } = useStreamRouteList( + routeKey, + defaultParams + ); + const { t } = useTranslation(); + const { searchValue, handleChange, handleClear } = useTableSearch(setParams); + + const columns = useMemo< + ProColumns[] + >(() => { + return [ + { + dataIndex: ['value', 'id'], + title: 'ID', + key: 'id', + valueType: 'text', + }, + { + dataIndex: ['value', 'server_addr'], + title: t('form.streamRoutes.serverAddr'), + key: 'server_addr', + valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'server_port'], + title: t('form.streamRoutes.serverPort'), + key: 'server_port', + valueType: 'text', + }, + { + dataIndex: ['value', 'upstream_id'], + title: t('form.upstreams.upstreamId'), + key: 'upstream_id', + render: (_, record) => { + const upstreamId = record.value.upstream_id; + const hasInlineUpstream = record.value.upstream && Object.keys(record.value.upstream).length > 0; + if (upstreamId) return {upstreamId}; + if (hasInlineUpstream) return {t('form.upstreams.inline')}; + return '-'; + }, + }, + { + dataIndex: ['value', 'sni'], + title: t('form.streamRoutes.sni'), + key: 'sni', + valueType: 'text', + ellipsis: true, + render: (_, record) => record.value.sni || '-', + }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, + }, + { + title: t('table.actions'), + valueType: 'option', + key: 'option', + width: 80, + render: (_, record) => , + }, + ]; + }, [t, ActionMenu, refetch]); + + return ( + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => handleChange(e.target.value)} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: AddButton ?? ( + + ), + }, + ], + }, + }} + /> + + ); +}; diff --git a/src/components/page/TableActionMenu.tsx b/src/components/page/TableActionMenu.tsx new file mode 100644 index 0000000000..618aa2ddb3 --- /dev/null +++ b/src/components/page/TableActionMenu.tsx @@ -0,0 +1,104 @@ +/** + * 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 { ActionIcon, Menu, Text } from '@mantine/core'; +import { modals } from '@mantine/modals'; +import { notifications } from '@mantine/notifications'; +import { useTranslation } from 'react-i18next'; + +import { queryClient } from '@/config/global'; +import { req } from '@/config/req'; +import IconCode from '~icons/material-symbols/code'; +import IconDelete from '~icons/material-symbols/delete'; +import IconForm from '~icons/material-symbols/list-alt'; +import IconMore from '~icons/material-symbols/more-vert'; + +export type TableActionMenuProps = { + /** Resource name for delete confirmation (e.g., "Route") */ + resourceName: string; + /** Resource ID or name for delete confirmation display */ + resourceTarget: string; + /** API endpoint for delete request */ + deleteApi: string; + /** Callback after successful deletion */ + onDeleteSuccess: () => void; + /** Callback when Form edit is clicked (opens drawer) */ + onFormEdit: () => void; + /** Callback when JSON edit is clicked (opens drawer) */ + onJsonEdit: () => void; +}; + +export const TableActionMenu = (props: TableActionMenuProps) => { + const { resourceName, resourceTarget, deleteApi, onDeleteSuccess, onFormEdit, onJsonEdit } = props; + const { t } = useTranslation(); + + const handleDeleteClick = () => { + modals.openConfirmModal({ + centered: true, + confirmProps: { color: 'red' }, + title: t('info.delete.title', { name: resourceName }), + children: ( + + {t('info.delete.content', { name: resourceName })} + + {resourceTarget} + + {t('mark.question')} + + ), + labels: { confirm: t('form.btn.delete'), cancel: t('form.btn.cancel') }, + onConfirm: () => + req + .delete(deleteApi) + .then(() => onDeleteSuccess?.()) + .then(() => { + notifications.show({ + message: t('info.delete.success', { name: resourceName }), + color: 'green', + }); + queryClient.invalidateQueries(); + }), + }); + }; + + return ( + + + + + + + + + } onClick={onFormEdit}> + {t('form.view.editWithForm')} + + } onClick={onJsonEdit}> + {t('form.view.editWithJSON')} + + + } color="red" onClick={handleDeleteClick}> + {t('form.btn.delete')} + + + + ); +}; diff --git a/src/components/page/ToAddPageBtn.tsx b/src/components/page/ToAddPageBtn.tsx index 4454581a6c..910860c2b8 100644 --- a/src/components/page/ToAddPageBtn.tsx +++ b/src/components/page/ToAddPageBtn.tsx @@ -14,12 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Button, Menu } from '@mantine/core'; import type { LinkProps } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { RouteLinkBtn } from '@/components/Btn'; import type { FileRoutesByTo } from '@/routeTree.gen'; import IconPlus from '~icons/material-symbols/add'; +import IconCode from '~icons/material-symbols/code'; +import IconChevronDown from '~icons/material-symbols/expand-more'; +import IconForm from '~icons/material-symbols/list-alt'; export type ToAddPageBtnProps = { to: keyof FilterKeys; @@ -40,6 +45,49 @@ export const ToAddPageBtn = ({ to, params, label }: ToAddPageBtnProps) => { ); }; +export type ToAddPageDropdownProps = { + to: string; + label: string; + params?: Record; +}; + +export const ToAddPageDropdown = ({ to, label, params }: ToAddPageDropdownProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleFormClick = () => { + navigate({ to, params }); + }; + + const handleJsonClick = () => { + navigate({ to, params, search: { mode: 'json' } }); + }; + + return ( + + + + + + + } onClick={handleFormClick}> + {t('form.view.createWithForm')} + + } onClick={handleJsonClick}> + {t('form.view.createWithJSON')} + + + + ); +}; + export type ToDetailPageBtnProps = { to: | keyof FilterKeys @@ -55,3 +103,44 @@ export const ToDetailPageBtn = (props: ToDetailPageBtnProps) => { ); }; + +export type ToDetailPageDropdownProps = { + to: string; + params: Record; +}; + +export const ToDetailPageDropdown = ({ to, params }: ToDetailPageDropdownProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleFormClick = () => { + navigate({ to, params }); + }; + + const handleJsonClick = () => { + navigate({ to, params, search: { mode: 'json' } }); + }; + + return ( + + + + + + + } onClick={handleFormClick}> + {t('form.view.editWithForm')} + + } onClick={handleJsonClick}> + {t('form.view.editWithJSON')} + + + + ); +}; diff --git a/src/hooks/useDirtyState.ts b/src/hooks/useDirtyState.ts new file mode 100644 index 0000000000..8efdef9987 --- /dev/null +++ b/src/hooks/useDirtyState.ts @@ -0,0 +1,220 @@ +/** + * 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 { useCallback, useMemo, useRef, useState } from 'react'; + +/** + * Deep equality comparison for objects and arrays + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (typeof a !== typeof b) return false; + + if (a === null || b === null) return a === b; + + if (typeof a !== 'object') return false; + + if (Array.isArray(a) !== Array.isArray(b)) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => deepEqual(item, b[index])); + } + + const keysA = Object.keys(a as object); + const keysB = Object.keys(b as object); + + if (keysA.length !== keysB.length) return false; + + return keysA.every((key) => + deepEqual( + (a as Record)[key], + (b as Record)[key] + ) + ); +} + +/** + * Get changed fields between two objects + */ +function getChangedFields( + original: unknown, + current: unknown, + prefix = '' +): string[] { + if (typeof original !== 'object' || original === null) { + return deepEqual(original, current) ? [] : [prefix || 'value']; + } + + if (typeof current !== 'object' || current === null) { + return [prefix || 'value']; + } + + const changedFields: string[] = []; + const allKeys = new Set([ + ...Object.keys(original as object), + ...Object.keys(current as object), + ]); + + allKeys.forEach((key) => { + const origVal = (original as Record)[key]; + const currVal = (current as Record)[key]; + const fieldPath = prefix ? `${prefix}.${key}` : key; + + if (!deepEqual(origVal, currVal)) { + if ( + typeof origVal === 'object' && + origVal !== null && + typeof currVal === 'object' && + currVal !== null + ) { + changedFields.push(...getChangedFields(origVal, currVal, fieldPath)); + } else { + changedFields.push(fieldPath); + } + } + }); + + return changedFields; +} + +export interface DirtyState { + /** Original value (from server) */ + original: T | undefined; + /** Current value (with local edits) */ + current: T | undefined; + /** Whether current differs from original */ + isDirty: boolean; + /** List of field paths that have changed */ + changedFields: string[]; + /** Update current value */ + update: (newValue: T) => void; + /** Reset to a new original value (and clear dirty state) */ + reset: (newOriginal?: T) => void; + /** Check if a specific field is dirty */ + isFieldDirty: (fieldPath: string) => boolean; +} + +/** + * Hook for tracking dirty state with deep comparison + * + * @param initialOriginal - The original value to compare against + * @returns DirtyState object with current value, isDirty flag, and update functions + * + * @example + * ```tsx + * const { current, isDirty, update, reset } = useDirtyState(serverData); + * + * // Update current value + * update({ ...current, name: 'New Name' }); + * + * // Check if dirty + * if (isDirty) { + * // Show warning before navigation + * } + * + * // Reset after save + * reset(savedData); + * ``` + */ +export function useDirtyState(initialOriginal: T | undefined): DirtyState { + const [original, setOriginal] = useState(initialOriginal); + const [current, setCurrent] = useState(initialOriginal); + + // Use ref to track if we've initialized with initialOriginal + const initializedRef = useRef(false); + + // Update original when initialOriginal changes (e.g., after data fetch) + if (initialOriginal !== undefined && !initializedRef.current) { + initializedRef.current = true; + setOriginal(initialOriginal); + setCurrent(initialOriginal); + } + + const isDirty = useMemo( + () => !deepEqual(original, current), + [original, current] + ); + + const changedFields = useMemo( + () => (isDirty ? getChangedFields(original, current) : []), + [original, current, isDirty] + ); + + const update = useCallback((newValue: T) => { + setCurrent(newValue); + }, []); + + const reset = useCallback((newOriginal?: T) => { + if (newOriginal !== undefined) { + setOriginal(newOriginal); + setCurrent(newOriginal); + } else { + setCurrent(original); + } + }, [original]); + + const isFieldDirty = useCallback( + (fieldPath: string) => changedFields.includes(fieldPath), + [changedFields] + ); + + return { + original, + current, + isDirty, + changedFields, + update, + reset, + isFieldDirty, + }; +} + +/** + * Hook for tracking dirty state of a string (e.g., JSON editor) + * Simpler version that just compares strings + */ +export function useStringDirtyState(initialValue: string): DirtyState { + const [original, setOriginal] = useState(initialValue); + const [current, setCurrent] = useState(initialValue); + + const isDirty = original !== current; + + const update = useCallback((newValue: string) => { + setCurrent(newValue); + }, []); + + const reset = useCallback((newOriginal?: string) => { + if (newOriginal !== undefined) { + setOriginal(newOriginal); + setCurrent(newOriginal); + } else { + setCurrent(original); + } + }, [original]); + + return { + original, + current, + isDirty, + changedFields: isDirty ? ['value'] : [], + update, + reset, + isFieldDirty: () => isDirty, + }; +} diff --git a/src/hooks/useTableSearch.ts b/src/hooks/useTableSearch.ts new file mode 100644 index 0000000000..4ff27957b7 --- /dev/null +++ b/src/hooks/useTableSearch.ts @@ -0,0 +1,51 @@ +/** + * 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 { useDebouncedCallback } from '@mantine/hooks'; +import { useState } from 'react'; + +type SetParamsFunction = (params: { name?: string; page?: number }) => void; + +/** + * Custom hook for table search functionality with debouncing. + * Provides consistent search behavior across all table views. + * + * @param setParams - Function to update the search parameters + * @param debounceMs - Debounce delay in milliseconds (default: 300) + */ +export const useTableSearch = (setParams: SetParamsFunction, debounceMs = 300) => { + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, debounceMs); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleChange = (value: string) => { + setSearchValue(value); + handleSearch(value); + }; + + return { + searchValue, + handleChange, + handleClear, + }; +}; diff --git a/src/hooks/useUnifiedResourceState.ts b/src/hooks/useUnifiedResourceState.ts new file mode 100644 index 0000000000..3a067e2329 --- /dev/null +++ b/src/hooks/useUnifiedResourceState.ts @@ -0,0 +1,369 @@ +/** + * 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 { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export type ViewMode = 'form' | 'json'; + +export interface TransformConfig { + /** Transform API data to internal format */ + apiToInternal: (api: TApi) => TInternal; + /** Transform internal format to API format */ + internalToApi: (internal: TInternal) => TApi; + /** Transform internal format to form format */ + internalToForm: (internal: TInternal) => TForm; + /** Transform form format to internal format */ + formToInternal: (form: TForm) => TInternal; + /** Transform internal format to JSON string */ + internalToJson: (internal: TInternal) => string; + /** Transform JSON string to internal format */ + jsonToInternal: (json: string) => TInternal; +} + +export interface UnifiedResourceState { + // ============================================ + // Server State + // ============================================ + /** Original data from server */ + serverData: TApi | undefined; + /** Whether data is loading */ + isLoading: boolean; + /** Whether there was an error loading */ + isError: boolean; + /** Refetch data from server */ + refetch: () => Promise; + + // ============================================ + // Draft State + // ============================================ + /** Current form draft (with local edits) */ + formDraft: TForm | undefined; + /** Current JSON draft (with local edits) */ + jsonDraft: string; + + // ============================================ + // Dirty Tracking + // ============================================ + /** Whether form view has unsaved changes */ + isFormDirty: boolean; + /** Whether JSON view has unsaved changes */ + isJsonDirty: boolean; + /** Whether either view has unsaved changes */ + isDirty: boolean; + + // ============================================ + // View Mode + // ============================================ + /** Currently active view */ + activeView: ViewMode; + /** Whether currently in read-only mode */ + readOnly: boolean; + + // ============================================ + // Actions + // ============================================ + /** Switch to a different view (syncs data first) */ + setActiveView: (view: ViewMode) => void; + /** Toggle read-only mode */ + setReadOnly: (readOnly: boolean) => void; + /** Update form draft */ + updateFormDraft: (data: TForm) => void; + /** Update JSON draft */ + updateJsonDraft: (json: string) => void; + /** Sync form changes to JSON (call before switching to JSON view) */ + syncFormToJson: () => void; + /** Sync JSON changes to form (call before switching to form view) */ + syncJsonToForm: () => void; + /** Reset all drafts to server state */ + resetDrafts: () => void; + /** Save current active view's data */ + save: () => Promise; + + // ============================================ + // Mutation State + // ============================================ + /** Whether save is in progress */ + isSaving: boolean; + /** Last save error */ + saveError: Error | null; +} + +export interface UseUnifiedResourceStateOptions { + /** React Query query result */ + query: UseQueryResult<{ value: TApi }, Error>; + /** React Query mutation result */ + mutation: UseMutationResult; + /** Transformation functions */ + transforms: TransformConfig; + /** Callback after successful save */ + onSaveSuccess?: () => void; +} + +/** + * Unified Resource State Hook + * + * Provides a single source of truth for resource editing across Form and JSON views. + * Handles: + * - Server state management (via React Query) + * - Local draft state for both views + * - Bidirectional synchronization between views + * - Dirty state tracking + * - Save operations + * + * @example + * ```tsx + * const unifiedState = useUnifiedResourceState({ + * query: routeQuery, + * mutation: putRouteMutation, + * transforms: { + * apiToInternal, + * internalToApi, + * internalToForm, + * formToInternal, + * internalToJson, + * jsonToInternal, + * }, + * }); + * ``` + */ +// Helper to deep clone and unfreeze objects from Immer +const deepClone = (obj: T): T => JSON.parse(JSON.stringify(obj)); + +export function useUnifiedResourceState( + options: UseUnifiedResourceStateOptions +): UnifiedResourceState { + const { query, mutation, transforms, onSaveSuccess } = options; + + // ============================================ + // Local State + // ============================================ + const [activeView, setActiveViewState] = useState('form'); + const [readOnly, setReadOnly] = useState(true); + const [formDraft, setFormDraft] = useState(undefined); + const [jsonDraft, setJsonDraft] = useState('{}'); + const [saveError, setSaveError] = useState(null); + + // Track the original internal data for dirty comparison + const originalInternalRef = useRef(undefined); + + // Track if we've initialized from server data + const initializedRef = useRef(false); + + // ============================================ + // Derived State + // ============================================ + const serverData = query.data?.value; + const isLoading = query.isLoading; + const isError = query.isError; + const isSaving = mutation.isPending; + + // Initialize drafts when server data arrives + useEffect(() => { + if (serverData && !initializedRef.current) { + initializedRef.current = true; + // Deep clone to avoid frozen object issues from React Query and Immer + const clonedData = deepClone(serverData); + const internal = transforms.apiToInternal(clonedData); + originalInternalRef.current = deepClone(internal); + setFormDraft(deepClone(transforms.internalToForm(internal))); + setJsonDraft(transforms.internalToJson(internal)); + } + }, [serverData, transforms]); + + // Also update when server data changes after save + useEffect(() => { + if (serverData && initializedRef.current && readOnly) { + // Deep clone to avoid frozen object issues from React Query and Immer + const clonedData = deepClone(serverData); + const internal = transforms.apiToInternal(clonedData); + originalInternalRef.current = deepClone(internal); + setFormDraft(deepClone(transforms.internalToForm(internal))); + setJsonDraft(transforms.internalToJson(internal)); + } + }, [serverData, transforms, readOnly]); + + // ============================================ + // Dirty State Calculation + // ============================================ + const isFormDirty = useMemo(() => { + if (!formDraft || !originalInternalRef.current) return false; + try { + const currentInternal = transforms.formToInternal(formDraft); + const originalJson = transforms.internalToJson(originalInternalRef.current); + const currentJson = transforms.internalToJson(currentInternal); + return originalJson !== currentJson; + } catch { + return true; // Assume dirty if transformation fails + } + }, [formDraft, transforms]); + + const isJsonDirty = useMemo(() => { + if (!originalInternalRef.current) return false; + try { + const originalJson = transforms.internalToJson(originalInternalRef.current); + // Normalize JSON for comparison (remove whitespace differences) + const normalizedOriginal = JSON.stringify(JSON.parse(originalJson)); + const normalizedCurrent = JSON.stringify(JSON.parse(jsonDraft)); + return normalizedOriginal !== normalizedCurrent; + } catch { + return true; // Assume dirty if parse fails + } + }, [jsonDraft, transforms]); + + const isDirty = activeView === 'form' ? isFormDirty : isJsonDirty; + + // ============================================ + // Actions + // ============================================ + const syncFormToJson = useCallback(() => { + if (!formDraft) return; + try { + const internal = transforms.formToInternal(formDraft); + setJsonDraft(transforms.internalToJson(internal)); + } catch { + // Keep existing JSON if transformation fails + } + }, [formDraft, transforms]); + + const syncJsonToForm = useCallback(() => { + try { + const internal = transforms.jsonToInternal(jsonDraft); + // Deep clone to avoid frozen object issues from Immer + setFormDraft(deepClone(transforms.internalToForm(internal))); + } catch { + // Keep existing form if transformation fails + } + }, [jsonDraft, transforms]); + + const setActiveView = useCallback( + (view: ViewMode) => { + if (view === activeView) return; + + // Sync data before switching views + if (activeView === 'form' && view === 'json') { + syncFormToJson(); + } else if (activeView === 'json' && view === 'form') { + syncJsonToForm(); + } + + setActiveViewState(view); + }, + [activeView, syncFormToJson, syncJsonToForm] + ); + + const updateFormDraft = useCallback((data: TForm) => { + setFormDraft(data); + }, []); + + const updateJsonDraft = useCallback((json: string) => { + setJsonDraft(json); + }, []); + + const resetDrafts = useCallback(() => { + if (!originalInternalRef.current) return; + // Deep clone to avoid frozen object issues from Immer + setFormDraft(deepClone(transforms.internalToForm(originalInternalRef.current))); + setJsonDraft(transforms.internalToJson(originalInternalRef.current)); + }, [transforms]); + + const refetch = useCallback(async () => { + await query.refetch(); + }, [query]); + + const save = useCallback(async (): Promise => { + setSaveError(null); + + try { + let apiData: TApi; + + if (activeView === 'form') { + if (!formDraft) return false; + const internal = transforms.formToInternal(formDraft); + apiData = transforms.internalToApi(internal); + } else { + const internal = transforms.jsonToInternal(jsonDraft); + apiData = transforms.internalToApi(internal); + } + + await mutation.mutateAsync(apiData); + + // Update original reference after successful save + const internal = + activeView === 'form' + ? transforms.formToInternal(formDraft!) + : transforms.jsonToInternal(jsonDraft); + originalInternalRef.current = internal; + + // Refetch to get updated data + await query.refetch(); + + // Switch back to read-only mode + setReadOnly(true); + + onSaveSuccess?.(); + + return true; + } catch (error) { + setSaveError(error instanceof Error ? error : new Error('Save failed')); + return false; + } + }, [ + activeView, + formDraft, + jsonDraft, + mutation, + query, + transforms, + onSaveSuccess, + ]); + + return { + // Server state + serverData, + isLoading, + isError, + refetch, + + // Draft state + formDraft, + jsonDraft, + + // Dirty tracking + isFormDirty, + isJsonDirty, + isDirty, + + // View mode + activeView, + readOnly, + + // Actions + setActiveView, + setReadOnly, + updateFormDraft, + updateJsonDraft, + syncFormToJson, + syncJsonToForm, + resetDrafts, + save, + + // Mutation state + isSaving, + saveError, + }; +} diff --git a/src/hooks/useUnifiedRouteState.ts b/src/hooks/useUnifiedRouteState.ts new file mode 100644 index 0000000000..708bf1d8cb --- /dev/null +++ b/src/hooks/useUnifiedRouteState.ts @@ -0,0 +1,121 @@ +/** + * 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 { notifications } from '@mantine/notifications'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { getRouteQueryOptions } from '@/apis/hooks'; +import { putRouteReq } from '@/apis/routes'; +import type { RoutePutType } from '@/components/form-slice/FormPartRoute/schema'; +import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; +import { + apiToInternal, + formToInternal, + internalToApi, + internalToForm, + internalToJson, + jsonToInternal, + type RouteInternal, +} from '@/utils/route-transformer'; + +import { + type UnifiedResourceState, + useUnifiedResourceState, +} from './useUnifiedResourceState'; + +export type UnifiedRouteState = UnifiedResourceState< + RoutePutType, + APISIXType['Route'] +>; + +/** + * Route-specific unified state hook + * + * Wraps useUnifiedResourceState with Route-specific configuration: + * - React Query for route data + * - Route transformers for API/Form/JSON conversions + * - Success/error notifications + * + * @param id - Route ID to fetch and edit + * @returns UnifiedRouteState for managing route editing + * + * @example + * ```tsx + * const unifiedState = useUnifiedRouteState(routeId); + * + * // Use in component + * + * + * + * + * + * + * + * + * ``` + */ +export function useUnifiedRouteState(id: string): UnifiedRouteState { + const { t } = useTranslation(); + + // Query for route data + const routeQuery = useQuery(getRouteQueryOptions(id)); + + // Mutation for saving route + const putRouteMutation = useMutation({ + mutationFn: (data: APISIXType['Route']) => putRouteReq(req, data), + onSuccess: () => { + notifications.show({ + message: t('info.edit.success', { name: t('routes.singular') }), + color: 'green', + }); + }, + onError: (error) => { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + // Transform configuration + const transforms = useMemo( + () => ({ + apiToInternal: apiToInternal as ( + api: APISIXType['Route'] + ) => RouteInternal, + internalToApi: internalToApi as ( + internal: RouteInternal + ) => APISIXType['Route'], + internalToForm: internalToForm as (internal: RouteInternal) => RoutePutType, + formToInternal: formToInternal as (form: RoutePutType) => RouteInternal, + internalToJson, + jsonToInternal, + }), + [] + ); + + return useUnifiedResourceState( + { + query: routeQuery, + mutation: putRouteMutation, + transforms, + } + ); +} diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 0322e6a3a7..0c1a29c3f1 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -57,6 +57,19 @@ "json": { "parseError": "JSON Format ist ungültig" }, + "view": { + "formView": "Formularansicht", + "jsonView": "JSON-Ansicht", + "editJSON": "JSON bearbeiten", + "viewJSON": "JSON anzeigen", + "unsavedChanges": "Sie haben ungespeicherte Änderungen. Möchten Sie die Ansicht wirklich wechseln?", + "saveAndSwitch": "Speichern und Wechseln", + "discardAndSwitch": "Verwerfen und Wechseln", + "invalidJSON": "Ungültige JSON-Syntax", + "schemaValidationFailed": "Schema-Validierung fehlgeschlagen", + "jsonParseError": "Fehler beim Parsen von JSON", + "transformError": "Fehler beim Transformieren von Daten" + }, "plugins": { "addPlugin": "Plugin hinzufügen", "configId": "Plugin-Konfigurations-ID", @@ -360,7 +373,10 @@ "table": { "actions": "Aktionen", "disabled": "Deaktiviert", - "enabled": "Aktiviert" + "enabled": "Aktiviert", + "hosts": "Hosts", + "methods": "Methoden", + "updateTime": "Aktualisierungszeit" }, "upstreams": { "singular": "Upstream" diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 8195d80c7f..3307076a1c 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -15,20 +15,26 @@ "form": { "basic": { "desc": "Description", + "descPlaceholder": "Brief description of this resource", "labels": { "errorFormat": "The format of label is wrong, it should be `key:value`", "key": "Key", "placeholder": "Input text like `key:value`, then enter or blur", "title": "Labels", - "value": "Value" + "value": "Value", + "desc": "Key-value pairs for organizing and filtering resources" }, "name": "Name", + "namePlaceholder": "my-resource-name", + "nameDesc": "A unique identifier for this resource", "status": "Status", + "statusDesc": "Enable or disable this resource", "statusOption": { "0": "Disabled", "1": "Enabled" }, - "title": "Basic Infomation" + "title": "Basic Information", + "required": "Required" }, "btn": { "add": "Add", @@ -57,6 +63,28 @@ "json": { "parseError": "JSON format is not valid" }, + "view": { + "formView": "Form View", + "jsonView": "JSON View", + "editJSON": "Edit JSON", + "viewJSON": "View JSON", + "createWithForm": "Create with Form", + "createWithJSON": "Create with JSON", + "editWithForm": "Edit with Form", + "editWithJSON": "Edit with JSON", + "viewWithForm": "View with Form", + "viewWithJSON": "View with JSON", + "previewJSON": "Preview JSON", + "copyToClipboard": "Copy to Clipboard", + "copied": "Copied!", + "unsavedChanges": "You have unsaved changes. Are you sure you want to switch views?", + "saveAndSwitch": "Save and Switch", + "discardAndSwitch": "Discard and Switch", + "invalidJSON": "Invalid JSON syntax", + "schemaValidationFailed": "Schema validation failed", + "jsonParseError": "Failed to parse JSON", + "transformError": "Failed to transform data" + }, "plugins": { "addPlugin": "Add Plugin", "configId": "Plugin Config ID", @@ -74,18 +102,35 @@ }, "routes": { "enableWebsocket": "Enable WebSocket", + "enableWebsocketDesc": "Enable WebSocket proxy for this route", "filterFunc": "Filter Func", + "filterFuncPlaceholder": "function(vars) return vars[\"arg_name\"] == \"test\" end", + "filterFuncDesc": "Custom Lua function for request filtering", "host": "Host", + "hostPlaceholder": "example.com", + "hostDesc": "Match requests with this host header", "hosts": "Hosts", + "hostsDesc": "Match requests with any of these host headers", "matchRules": "Match Rules", + "matchRulesDesc": "Define conditions for routing requests. At least URI or URIs is required.", "methods": "HTTP Methods", + "methodsDesc": "Allowed HTTP methods. Leave empty to allow all methods.", "priority": "Priority", + "priorityDesc": "Higher priority routes are matched first (default: 0)", "remoteAddr": "Remote Address", + "remoteAddrPlaceholder": "192.168.1.0/24", + "remoteAddrDesc": "Match requests from this client IP/CIDR", "remoteAddrs": "Remote Addresses", + "remoteAddrsDesc": "Match requests from any of these client IPs/CIDRs", "service": "Service", + "serviceDesc": "Link this route to an existing service configuration", "uri": "URI", + "uriPlaceholder": "/api/v1/*", + "uriDesc": "Request path to match. Supports wildcards (*)", "uris": "URIs", - "vars": "Vars" + "urisDesc": "Multiple request paths to match", + "vars": "Vars", + "varsDesc": "Advanced matching rules using Nginx variables (JSON array format)" }, "search": "Search", "secrets": { @@ -141,7 +186,10 @@ "sni": "SNI", "snis": "SNIs", "ssl_protocols": "SSL Protocols", - "type": "Certificate Type" + "type": "Type", + "expiration": "Expiration", + "expired": "Expired", + "daysLeft": "{{days}}d left" }, "streamRoutes": { "protocol": { @@ -222,38 +270,54 @@ "identifier": "Upstream Identifier", "inline": "Inline Upstream Configuration", "keepalivePool": { - "idleTimeout": "IDLE Timeout", + "idleTimeout": "Idle Timeout", + "idleTimeoutDesc": "Time in seconds before idle connections are closed", "requests": "Requests", + "requestsDesc": "Max requests per connection before recycling", "size": "Size", - "title": "Keepalive Pool" + "sizeDesc": "Number of connections to keep alive in the pool", + "title": "Keepalive Pool", + "desc": "Configure connection pooling for better performance" }, "key": "Key", "keyDesc": "This will be valid when `type` is `chash`", "loadBalancing": "Load Balancing", + "loadBalancingDesc": "Configure how traffic is distributed across backend nodes", "nodes": { "action": { "title": "Action" }, - "add": "Add a Node", + "add": "Add Node", + "desc": "Backend server addresses that will receive proxied traffic", "host": { - "title": "Host" + "title": "Host", + "desc": "IP address or hostname of the backend server" }, + "label": "Target Nodes", "port": { - "title": "Port" + "title": "Port", + "desc": "Port number of the backend server (1-65535)" }, "priority": { - "title": "Priority" + "title": "Priority", + "desc": "Lower values are tried first (default: 0)" }, "title": "Nodes", "weight": { - "title": "Weight" + "title": "Weight", + "desc": "Load balancing weight (higher = more traffic)" } }, + "requiredDesc": "Provide either an Upstream ID to reference an existing upstream, or configure nodes directly.", "passHost": "Pass Host", + "passHostDesc": "How to pass the host header to upstream", "retries": "Retries", + "retriesDesc": "Number of retry attempts on failure", "retry": "Retry", - "retryTimeout": "Retry timeout", + "retryTimeout": "Retry Timeout", + "retryTimeoutDesc": "Maximum time in seconds for retries", "scheme": "Scheme", + "schemeDesc": "Protocol for upstream communication", "serviceDiscovery": { "serviceName": "Service Name", "title": "Service Discovery" @@ -264,9 +328,13 @@ }, "timeout": { "connect": "Connect", + "connectDesc": "Connection timeout in seconds", "read": "Read", + "readDesc": "Read timeout in seconds", "send": "Send", - "title": "Timeout" + "sendDesc": "Send timeout in seconds", + "title": "Timeout", + "desc": "Configure timeout settings for upstream connections" }, "title": "Upstream", "tls": { @@ -275,9 +343,11 @@ "clientCertKeyPair": "Client Cert Key Pair", "clientKey": "Client Key", "title": "TLS", - "verify": "Verify" + "verify": "Verify", + "desc": "Configure TLS/SSL settings for upstream connections" }, "type": "Type", + "typeDesc": "Load balancing algorithm to distribute traffic", "updateTime": "Update Time", "upstreamHost": "Upstream Host", "upstreamHostDesc": "Set this when `pass_host` is `rewrite`", @@ -360,7 +430,10 @@ "table": { "actions": "Actions", "disabled": "Disabled", - "enabled": "Enabled" + "enabled": "Enabled", + "hosts": "Hosts", + "methods": "Methods", + "updateTime": "Update Time" }, "upstreams": { "singular": "Upstream" diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 1af5a22b8c..1a8488f3dc 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -57,6 +57,19 @@ "json": { "parseError": "El formato JSON no es válido" }, + "view": { + "formView": "Vista de Formulario", + "jsonView": "Vista JSON", + "editJSON": "Editar JSON", + "viewJSON": "Ver JSON", + "unsavedChanges": "Tiene cambios sin guardar. ¿Está seguro de que desea cambiar de vista?", + "saveAndSwitch": "Guardar y Cambiar", + "discardAndSwitch": "Descartar y Cambiar", + "invalidJSON": "Sintaxis JSON inválida", + "schemaValidationFailed": "Falló la validación del esquema", + "jsonParseError": "Error al analizar JSON", + "transformError": "Error al transformar datos" + }, "plugins": { "addPlugin": "Añadir Plugin", "configId": "ID de Configuración de Plugin", @@ -360,7 +373,10 @@ "table": { "actions": "Acciones", "disabled": "Deshabilitado", - "enabled": "Habilitado" + "enabled": "Habilitado", + "hosts": "Hosts", + "methods": "Métodos", + "updateTime": "Hora de Actualización" }, "upstreams": { "singular": "Upstream" diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json index d2fdec0ca6..e908d080f7 100644 --- a/src/locales/tr/common.json +++ b/src/locales/tr/common.json @@ -57,6 +57,19 @@ "json": { "parseError": "JSON formatı geçerli değil" }, + "view": { + "formView": "Form Görünümü", + "jsonView": "JSON Görünümü", + "editJSON": "JSON Düzenle", + "viewJSON": "JSON Görüntüle", + "unsavedChanges": "Kaydedilmemiş değişiklikleriniz var. Görünümü değiştirmek istediğinizden emin misiniz?", + "saveAndSwitch": "Kaydet ve Değiştir", + "discardAndSwitch": "Vazgeç ve Değiştir", + "invalidJSON": "Geçersiz JSON sözdizimi", + "schemaValidationFailed": "Şema doğrulaması başarısız oldu", + "jsonParseError": "JSON ayrıştırılamadı", + "transformError": "Veri dönüştürülemedi" + }, "plugins": { "addPlugin": "Plugin Ekle", "configId": "Plugin Config ID", @@ -360,7 +373,10 @@ "table": { "actions": "İşlemler", "disabled": "Pasif", - "enabled": "Aktif" + "enabled": "Aktif", + "hosts": "Hostlar", + "methods": "Metodlar", + "updateTime": "Güncelleme Zamanı" }, "upstreams": { "singular": "Upstream" diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 2f051bb936..0a55b46a73 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -57,6 +57,19 @@ "json": { "parseError": "JSON格式无效" }, + "view": { + "formView": "表单视图", + "jsonView": "JSON视图", + "editJSON": "编辑JSON", + "viewJSON": "查看JSON", + "unsavedChanges": "您有未保存的更改。确定要切换视图吗?", + "saveAndSwitch": "保存并切换", + "discardAndSwitch": "放弃并切换", + "invalidJSON": "无效的JSON语法", + "schemaValidationFailed": "架构验证失败", + "jsonParseError": "解析JSON失败", + "transformError": "数据转换失败" + }, "plugins": { "addPlugin": "添加插件", "configId": "插件配置ID", @@ -141,7 +154,10 @@ "sni": "SNI", "snis": "SNI 列表", "ssl_protocols": "SSL协议", - "type": "证书类型" + "type": "类型", + "expiration": "到期时间", + "expired": "已过期", + "daysLeft": "剩余{{days}}天" }, "streamRoutes": { "protocol": { @@ -360,7 +376,10 @@ "table": { "actions": "操作", "disabled": "已禁用", - "enabled": "已启用" + "enabled": "已启用", + "hosts": "主机", + "methods": "方法", + "updateTime": "更新时间" }, "upstreams": { "singular": "上游" diff --git a/src/routes/consumer_groups/add.tsx b/src/routes/consumer_groups/add.tsx index ed216f38d0..f8ce94bb86 100644 --- a/src/routes/consumer_groups/add.tsx +++ b/src/routes/consumer_groups/add.tsx @@ -15,39 +15,87 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; import { nanoid } from 'nanoid'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { putConsumerGroupReq } from '@/apis/consumer_groups'; import { 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 { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; -const ConsumerGroupAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Consumer Group creation template +const CONSUMER_GROUP_TEMPLATE = { + plugins: {}, +}; + +type Props = { + navigate: (res: APISIXType['RespConsumerGroupDetail']) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const ConsumerGroupAddForm = (props: Props) => { + const { navigate } = props; const { t } = useTranslation(); const router = useRouter(); const putConsumerGroup = useMutation({ mutationFn: (d: APISIXType['ConsumerGroupPut']) => - putConsumerGroupReq(req, d), - async onSuccess(response) { + putConsumerGroupReq(req, pipeProduce()(d)), + async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('consumerGroups.singular') }), color: 'green', }); - await router.navigate({ - to: '/consumer_groups/detail/$id', - params: { id: response.data.value.id }, - }); + await navigate(res); }, }); @@ -63,33 +111,109 @@ const ConsumerGroupAddForm = () => { return ( -
- putConsumerGroup.mutateAsync(pipeProduce()(d)) - )} - > + putConsumerGroup.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const ConsumerGroupAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(CONSUMER_GROUP_TEMPLATE, null, 2) + ); + + const putConsumerGroup = useMutation({ + mutationFn: (d: APISIXType['ConsumerGroupPut']) => putConsumerGroupReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('consumerGroups.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + // Auto-generate id if not provided + const dataWithId = { ...dataToCreate, id: dataToCreate.id || nanoid() }; + await putConsumerGroup.mutateAsync(dataWithId); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/consumer_groups/add' }); + + const navigateToDetail = (res: APISIXType['RespConsumerGroupDetail']) => + navigate({ + to: '/consumer_groups/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('consumerGroups.singular') })} (JSON)` + : t('info.add.title', { name: t('consumerGroups.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/consumer_groups/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/consumer_groups/detail.$id.tsx b/src/routes/consumer_groups/detail.$id.tsx index 8d396ab956..435f73841e 100644 --- a/src/routes/consumer_groups/detail.$id.tsx +++ b/src/routes/consumer_groups/detail.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { putConsumerGroupReq } from '@/apis/consumer_groups'; import { getConsumerGroupQueryOptions } from '@/apis/hooks'; @@ -35,117 +37,291 @@ import { FormPartPluginConfig } from '@/components/form-slice/FormPartPluginConf import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_CONSUMER_GROUPS } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { + setEditMode: (mode: EditMode) => void; id: string; - readOnly: boolean; - setReadOnly: (v: boolean) => void; + onDeleteSuccess: () => void; +}; + +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); }; +/** + * Form Edit Component - Always editable + */ const ConsumerGroupDetailForm = (props: Props) => { - const { id, readOnly, setReadOnly } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); const consumerGroupQuery = useSuspenseQuery(getConsumerGroupQueryOptions(id)); - const { data } = consumerGroupQuery; + const { data, isLoading, refetch } = consumerGroupQuery; + + const form = useForm({ + resolver: zodResolver(APISIX.ConsumerGroupPut), + shouldUnregister: true, + shouldFocusError: true, + mode: 'all', + }); + + useEffect(() => { + if (data?.value && !isLoading) { + form.reset(data.value); + } + }, [data, form, isLoading]); const putConsumerGroup = useMutation({ mutationFn: (d: APISIXType['ConsumerGroupPut']) => - putConsumerGroupReq(req, d), + putConsumerGroupReq(req, pipeProduce()({ ...d, id })), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('consumerGroups.singular') }), color: 'green', }); - consumerGroupQuery.refetch(); - setReadOnly(true); + await refetch(); }, }); - const form = useForm({ - resolver: zodResolver(APISIX.ConsumerGroupPut), - shouldUnregister: true, - shouldFocusError: true, - mode: 'all', - disabled: readOnly, - }); - - useEffect(() => { - form.reset(data.value); - }, [form, data.value]); - - if (!data) return ; + if (isLoading || !data) { + return ; + } return ( - -
- putConsumerGroup.mutateAsync(pipeProduce()({ ...d, id })) - )} - > - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putConsumerGroup.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { - const { id } = useParams({ from: '/consumer_groups/detail/$id' }); +/** + * JSON Edit Component - Always editable + */ +const ConsumerGroupDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const consumerGroupQuery = useSuspenseQuery(getConsumerGroupQueryOptions(id)); + const { data, isLoading, refetch } = consumerGroupQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (data?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = data.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [data, isLoading]); + + const putConsumerGroup = useMutation({ + mutationFn: (d: APISIXType['ConsumerGroupPut']) => putConsumerGroupReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('consumerGroups.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putConsumerGroup.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/consumer_groups' }); + }; + + if (isLoading || !data) { + return ; + } + return ( <> - - navigate({ to: '/consumer_groups' })} - /> -
- ), - })} + title={`${t('info.edit.title', { name: t('consumerGroups.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type ConsumerGroupDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const ConsumerGroupDetail = (props: ConsumerGroupDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/consumer_groups/detail/$id' }); + const { mode } = useSearch({ from: '/consumer_groups/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/consumer_groups' })} + /> + ); } export const Route = createFileRoute('/consumer_groups/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/consumer_groups/index.tsx b/src/routes/consumer_groups/index.tsx index 108ce06b6e..fd3dc20fb9 100644 --- a/src/routes/consumer_groups/index.tsx +++ b/src/routes/consumer_groups/index.tsx @@ -16,23 +16,66 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, Group, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getConsumerGroupListQueryOptions, useConsumerGroupList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { putConsumerGroupReq } from '@/apis/consumer_groups'; +import { getConsumerGroupListQueryOptions, getConsumerGroupQueryOptions, useConsumerGroupList } from '@/apis/hooks'; +import { FormPartPluginConfig } from '@/components/form-slice/FormPartPluginConfig'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_CONSUMER_GROUPS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; -function ConsumerGroupsList() { +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['ConsumerGroupPut'] => { + return data as APISIXType['ConsumerGroupPut']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['ConsumerGroupPut']): APISIXType['ConsumerGroupPut'] => { + return pipeProduce()(formData) as APISIXType['ConsumerGroupPut']; +}; + +function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useConsumerGroupList(); + const { data, isLoading, refetch, pagination, setParams } = useConsumerGroupList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< ProColumns[] @@ -49,16 +92,38 @@ function ConsumerGroupsList() { title: t('form.basic.name'), key: 'name', valueType: 'text', + ellipsis: true, }, { dataIndex: ['value', 'desc'], title: t('form.basic.desc'), key: 'desc', valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'plugins'], + title: t('form.plugins.label'), + key: 'plugins', + render: (_, record) => { + const plugins = record.value.plugins; + if (!plugins || Object.keys(plugins).length === 0) return '-'; + const pluginNames = Object.keys(plugins); + return ( + + {pluginNames.slice(0, 3).map((name) => ( + {name} + ))} + {pluginNames.length > 3 && ( + +{pluginNames.length - 3} + )} + + ); + }, }, { dataIndex: ['value', 'update_time'], - title: t('form.info.update_time'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', sorter: true, @@ -71,66 +136,99 @@ function ConsumerGroupsList() { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [refetch, t]); + }, [refetch, t, handleFormEdit, handleJsonEdit]); - return ( - - - ), - }, - ], - }, - }} - /> - - ); -} - -function RouteComponent() { - const { t } = useTranslation(); return ( <> - + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('consumerGroups.singular')} + queryOptions={getConsumerGroupQueryOptions(selectedId)} + schema={APISIX.ConsumerGroupPut} + toFormValues={toFormValues} + toApiData={(d) => toApiData({ ...d, id: selectedId })} + onSave={(data) => putConsumerGroupReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['consumerGroups'] })} + > + + + + + putConsumerGroupReq(req, data as APISIXType['ConsumerGroupPut'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['consumerGroups'] })} + /> + + )} ); } diff --git a/src/routes/consumers/add.tsx b/src/routes/consumers/add.tsx index 137cf6936d..c8bcc0ee30 100644 --- a/src/routes/consumers/add.tsx +++ b/src/routes/consumers/add.tsx @@ -15,36 +15,85 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { putConsumerReq } from '@/apis/consumers'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartConsumer } from '@/components/form-slice/FormPartConsumer'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; -const ConsumerAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Consumer creation template +const CONSUMER_TEMPLATE = { + username: '', +}; + +type Props = { + navigate: (username: string) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const ConsumerAddForm = (props: Props) => { + const { navigate } = props; const { t } = useTranslation(); const router = useRouter(); const putConsumer = useMutation({ - mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d), + mutationFn: (d: APISIXType['ConsumerPut']) => + putConsumerReq(req, pipeProduce()(d)), async onSuccess(_, res) { notifications.show({ message: t('info.add.success', { name: t('consumers.singular') }), color: 'green', }); - await router.navigate({ - to: '/consumers/detail/$username', - params: { username: res.username }, - }); + await navigate(res.username); }, }); @@ -57,32 +106,106 @@ const ConsumerAddForm = () => { return ( -
- putConsumer.mutateAsync(pipeProduce()(d)) - )} - > + putConsumer.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const ConsumerAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(CONSUMER_TEMPLATE, null, 2) + ); + + const putConsumer = useMutation({ + mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d), + async onSuccess(_, res) { + notifications.show({ + message: t('info.add.success', { name: t('consumers.singular') }), + color: 'green', + }); + await navigate(res.username); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await putConsumer.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/consumers/add' }); + + const navigateToDetail = (username: string) => + navigate({ + to: '/consumers/detail/$username', + params: { username }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('consumers.singular') })} (JSON)` + : t('info.add.title', { name: t('consumers.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/consumers/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/consumers/detail.$username/credentials/index.tsx b/src/routes/consumers/detail.$username/credentials/index.tsx index d433ba2b59..2402cf4d97 100644 --- a/src/routes/consumers/detail.$username/credentials/index.tsx +++ b/src/routes/consumers/detail.$username/credentials/index.tsx @@ -57,7 +57,7 @@ function CredentialsList() { }, { dataIndex: ['value', 'update_time'], - title: t('form.info.update_time'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', sorter: true, diff --git a/src/routes/consumers/detail.$username/index.tsx b/src/routes/consumers/detail.$username/index.tsx index c87835c5fd..1e88824064 100644 --- a/src/routes/consumers/detail.$username/index.tsx +++ b/src/routes/consumers/detail.$username/index.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { putConsumerReq } from '@/apis/consumers'; import { getConsumerQueryOptions } from '@/apis/hooks'; @@ -35,21 +37,60 @@ import { FormPartConsumer } from '@/components/form-slice/FormPartConsumer'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_CONSUMERS } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + username: string; + onDeleteSuccess: () => void; }; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ const ConsumerDetailForm = (props: Props) => { - const { readOnly, setReadOnly } = props; + const { setEditMode, username, onDeleteSuccess } = props; const { t } = useTranslation(); - const { username } = useParams({ from: '/consumers/detail/$username' }); + const navigate = useNavigate(); const consumerQuery = useSuspenseQuery(getConsumerQueryOptions(username)); const { data: consumerData, isLoading, refetch } = consumerQuery; @@ -59,7 +100,6 @@ const ConsumerDetailForm = (props: Props) => { shouldUnregister: true, shouldFocusError: true, mode: 'all', - disabled: readOnly, }); useEffect(() => { @@ -69,14 +109,14 @@ const ConsumerDetailForm = (props: Props) => { }, [consumerData, form, isLoading]); const putConsumer = useMutation({ - mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d), + mutationFn: (d: APISIXType['ConsumerPut']) => + putConsumerReq(req, pipeProduce()(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('consumers.singular') }), color: 'green', }); await refetch(); - setReadOnly(true); }, }); @@ -85,67 +125,203 @@ const ConsumerDetailForm = (props: Props) => { } return ( - -
{ - putConsumer.mutateAsync(pipeProduce()(d)); - })} - > - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putConsumer.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -const ConsumerDetailTab = () => { +/** + * JSON Edit Component - Always editable + */ +const ConsumerDetailJSON = (props: Props) => { + const { setEditMode, username, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); - const { username } = useParams({ from: '/consumers/detail/$username' }); const navigate = useNavigate(); + const consumerQuery = useSuspenseQuery(getConsumerQueryOptions(username)); + const { data: consumerData, isLoading, refetch } = consumerQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (consumerData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = consumerData.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [consumerData, isLoading]); + + const putConsumer = useMutation({ + mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('consumers.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putConsumer.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/consumers' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/consumer_groups' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('consumers.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); }; +type ConsumerDetailProps = { + username: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const ConsumerDetail = (props: ConsumerDetailProps) => { + const { username, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { username } = useParams({ from: '/consumers/detail/$username' }); + const { mode } = useSearch({ from: '/consumers/detail/$username/' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/consumers' })} + /> + ); +} export const Route = createFileRoute('/consumers/detail/$username/')({ - component: ConsumerDetailTab, + component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/consumers/index.tsx b/src/routes/consumers/index.tsx index b431ed3b4b..c57ae7c0f0 100644 --- a/src/routes/consumers/index.tsx +++ b/src/routes/consumers/index.tsx @@ -16,23 +16,66 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, Group, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getConsumerListQueryOptions, useConsumerList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { putConsumerReq } from '@/apis/consumers'; +import { getConsumerListQueryOptions, getConsumerQueryOptions, useConsumerList } from '@/apis/hooks'; +import { FormPartConsumer } from '@/components/form-slice/FormPartConsumer'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_CONSUMERS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; -function ConsumersList() { +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['ConsumerPut'] => { + return data as APISIXType['ConsumerPut']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['ConsumerPut']): APISIXType['ConsumerPut'] => { + return pipeProduce()(formData) as APISIXType['ConsumerPut']; +}; + +function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useConsumerList(); + const { data, isLoading, refetch, pagination, setParams } = useConsumerList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedUsername, setSelectedUsername] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((username: string) => { + setSelectedUsername(username); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((username: string) => { + setSelectedUsername(username); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo[]>(() => { return [ @@ -41,16 +84,48 @@ function ConsumersList() { title: t('form.consumers.username'), key: 'username', valueType: 'text', + ellipsis: true, }, { dataIndex: ['value', 'desc'], title: t('form.basic.desc'), key: 'desc', valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'group_id'], + title: t('form.consumers.groupId'), + key: 'group_id', + valueType: 'text', + render: (_, record) => { + const groupId = record.value.group_id; + return groupId ? {groupId} : '-'; + }, + }, + { + dataIndex: ['value', 'plugins'], + title: t('form.plugins.label'), + key: 'plugins', + render: (_, record) => { + const plugins = record.value.plugins; + if (!plugins || Object.keys(plugins).length === 0) return '-'; + const pluginNames = Object.keys(plugins); + return ( + + {pluginNames.slice(0, 2).map((name) => ( + {name} + ))} + {pluginNames.length > 2 && ( + +{pluginNames.length - 2} + )} + + ); + }, }, { dataIndex: ['value', 'update_time'], - title: t('form.info.update_time'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', sorter: true, @@ -63,66 +138,99 @@ function ConsumersList() { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.username)} + onJsonEdit={() => handleJsonEdit(record.value.username)} + /> + ), }, ]; - }, [refetch, t]); + }, [refetch, t, handleFormEdit, handleJsonEdit]); - return ( - - - ), - }, - ], - }, - }} - /> - - ); -} - -function RouteComponent() { - const { t } = useTranslation(); return ( <> - + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + + {selectedUsername && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('consumers.singular')} + queryOptions={getConsumerQueryOptions(selectedUsername)} + schema={APISIX.ConsumerPut} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putConsumerReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['consumers'] })} + > + + + + + putConsumerReq(req, data as APISIXType['ConsumerPut'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['consumers'] })} + /> + + )} ); } diff --git a/src/routes/global_rules/add.tsx b/src/routes/global_rules/add.tsx index 80a7873413..6e0a188edc 100644 --- a/src/routes/global_rules/add.tsx +++ b/src/routes/global_rules/add.tsx @@ -15,29 +15,76 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; import { createFileRoute, - useRouter as useReactRouter, + useNavigate, + useRouter, + useSearch, } from '@tanstack/react-router'; import { nanoid } from 'nanoid'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { putGlobalRuleReq } from '@/apis/global_rules'; import { 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 { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; import { APISIX } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; -const GlobalRuleAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Global Rule creation template +const GLOBAL_RULE_TEMPLATE = { + plugins: {}, +}; + +type Props = { + navigate: (res: APISIXType['RespGlobalRuleDetail']) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { const { t } = useTranslation(); - const router = useReactRouter(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + setPreviewJson(JSON.stringify(formData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const GlobalRuleAddForm = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const router = useRouter(); const putGlobalRule = useMutation({ mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d), @@ -47,10 +94,7 @@ const GlobalRuleAddForm = () => { message: t('info.add.success', { name: t('globalRules.singular') }), color: 'green', }); - await router.navigate({ - to: '/global_rules/detail/$id', - params: { id: res.data.value.id }, - }); + await navigate(res); }, }); @@ -70,26 +114,106 @@ const GlobalRuleAddForm = () => {
putGlobalRule.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + + ); }; +const GlobalRuleAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(GLOBAL_RULE_TEMPLATE, null, 2) + ); + + const putGlobalRule = useMutation({ + mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('globalRules.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + // Auto-generate id if not provided + const dataWithId = { ...dataToCreate, id: dataToCreate.id || nanoid() }; + await putGlobalRule.mutateAsync(dataWithId); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/global_rules/add' }); + + const navigateToDetail = (res: APISIXType['RespGlobalRuleDetail']) => + navigate({ + to: '/global_rules/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('globalRules.singular') })} (JSON)` + : t('info.add.title', { name: t('globalRules.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/global_rules/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/global_rules/detail.$id.tsx b/src/routes/global_rules/detail.$id.tsx index 9000a8a724..67c02b218c 100644 --- a/src/routes/global_rules/detail.$id.tsx +++ b/src/routes/global_rules/detail.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { putGlobalRuleReq } from '@/apis/global_rules'; import { getGlobalRuleQueryOptions } from '@/apis/hooks'; @@ -35,20 +37,61 @@ import { FormPartGlobalRules } from '@/components/form-slice/FormPartGlobalRules import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_GLOBAL_RULES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + id: string; + onDeleteSuccess: () => void; }; + +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + setPreviewJson(JSON.stringify(formData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ const GlobalRuleDetailForm = (props: Props) => { - const { readOnly, setReadOnly } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const { id } = useParams({ from: '/global_rules/detail/$id' }); + const navigate = useNavigate(); + const detailReq = useSuspenseQuery(getGlobalRuleQueryOptions(id)); + const { data, isLoading, refetch } = detailReq; const form = useForm({ resolver: zodResolver(APISIX.GlobalRulePut), @@ -56,14 +99,13 @@ const GlobalRuleDetailForm = (props: Props) => { shouldFocusError: true, defaultValues: {}, mode: 'onChange', - disabled: readOnly, }); useEffect(() => { - if (detailReq.data?.value) { - form.reset(detailReq.data.value); + if (data?.value && !isLoading) { + form.reset(data.value); } - }, [detailReq.data, form]); + }, [data, form, isLoading]); const putGlobalRule = useMutation({ mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d), @@ -72,68 +114,212 @@ const GlobalRuleDetailForm = (props: Props) => { message: t('info.edit.success', { name: t('globalRules.singular') }), color: 'green', }); - await detailReq.refetch(); - setReadOnly(true); + await refetch(); }, }); + if (isLoading) { + return ; + } + return ( - -
putGlobalRule.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putGlobalRule.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { - const { id } = useParams({ from: '/global_rules/detail/$id' }); +/** + * JSON Edit Component - Always editable + */ +const GlobalRuleDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const detailReq = useSuspenseQuery(getGlobalRuleQueryOptions(id)); + const { data, isLoading, refetch } = detailReq; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (data?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = data.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [data, isLoading]); + + const putGlobalRule = useMutation({ + mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('globalRules.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putGlobalRule.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/global_rules' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/global_rules' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('globalRules.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type GlobalRuleDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const GlobalRuleDetail = (props: GlobalRuleDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/global_rules/detail/$id' }); + const { mode } = useSearch({ from: '/global_rules/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/global_rules' })} + /> + ); } export const Route = createFileRoute('/global_rules/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/global_rules/index.tsx b/src/routes/global_rules/index.tsx index 248455efae..23eaa3a169 100644 --- a/src/routes/global_rules/index.tsx +++ b/src/routes/global_rules/index.tsx @@ -16,39 +16,69 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, Group, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getGlobalRuleListQueryOptions, useGlobalRuleList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { putGlobalRuleReq } from '@/apis/global_rules'; +import { getGlobalRuleListQueryOptions, getGlobalRuleQueryOptions, useGlobalRuleList } from '@/apis/hooks'; +import { FormPartGlobalRules } from '@/components/form-slice/FormPartGlobalRules'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_GLOBAL_RULES } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['GlobalRulePut'] => { + return data as APISIXType['GlobalRulePut']; +}; +// Transform form values to API data +const toApiData = (formData: APISIXType['GlobalRulePut']): APISIXType['GlobalRulePut'] => { + return pipeProduce()(formData) as APISIXType['GlobalRulePut']; +}; function RouteComponent() { const { t } = useTranslation(); + const { data, isLoading, refetch, pagination, setParams } = useGlobalRuleList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); - return ( - <> - - - - ); -} + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); -function GlobalRulesList() { - const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useGlobalRuleList(); + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< - ProColumns[] + ProColumns[] >(() => { return [ { @@ -57,61 +87,135 @@ function GlobalRulesList() { key: 'id', valueType: 'text', }, + { + dataIndex: ['value', 'plugins'], + title: t('form.plugins.label'), + key: 'plugins', + render: (_, record) => { + const plugins = record.value.plugins; + if (!plugins || Object.keys(plugins).length === 0) return '-'; + const pluginNames = Object.keys(plugins); + return ( + + {pluginNames.slice(0, 3).map((name) => ( + {name} + ))} + {pluginNames.length > 3 && ( + +{pluginNames.length - 3} + )} + + ); + }, + }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, + }, { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [t, refetch]); + }, [t, refetch, handleFormEdit, handleJsonEdit]); return ( - - - ), - }, - ], - }, - }} - /> - + <> + + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('globalRules.singular')} + queryOptions={getGlobalRuleQueryOptions(selectedId)} + schema={APISIX.GlobalRulePut} + toFormValues={toFormValues} + toApiData={(d) => toApiData({ ...d, id: selectedId })} + onSave={(data) => putGlobalRuleReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['globalRules'] })} + > + + + + + putGlobalRuleReq(req, data as APISIXType['GlobalRulePut'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['globalRules'] })} + /> + + )} + ); } diff --git a/src/routes/plugin_configs/add.tsx b/src/routes/plugin_configs/add.tsx index ee9ef397bc..0f292f6d25 100644 --- a/src/routes/plugin_configs/add.tsx +++ b/src/routes/plugin_configs/add.tsx @@ -15,39 +15,87 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; import { nanoid } from 'nanoid'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { putPluginConfigReq } from '@/apis/plugin_configs'; import { 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 { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; -const PluginConfigAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Plugin Config creation template +const PLUGIN_CONFIG_TEMPLATE = { + plugins: {}, +}; + +type Props = { + navigate: (res: APISIXType['RespPluginConfigDetail']) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const PluginConfigAddForm = (props: Props) => { + const { navigate } = props; const { t } = useTranslation(); const router = useRouter(); const putPluginConfig = useMutation({ mutationFn: (d: APISIXType['PluginConfigPut']) => - putPluginConfigReq(req, d), - async onSuccess(response) { + putPluginConfigReq(req, pipeProduce()(d)), + async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('pluginConfigs.singular') }), color: 'green', }); - await router.navigate({ - to: '/plugin_configs/detail/$id', - params: { id: response.data.value.id }, - }); + await navigate(res); }, }); @@ -63,33 +111,109 @@ const PluginConfigAddForm = () => { return ( -
- putPluginConfig.mutateAsync(pipeProduce()(d)) - )} - > + putPluginConfig.mutateAsync(d))}> - - {t('form.btn.add')} + + + + + + {t('form.btn.save')} + +
); }; +const PluginConfigAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(PLUGIN_CONFIG_TEMPLATE, null, 2) + ); + + const putPluginConfig = useMutation({ + mutationFn: (d: APISIXType['PluginConfigPut']) => putPluginConfigReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('pluginConfigs.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + // Auto-generate id if not provided + const dataWithId = { ...dataToCreate, id: dataToCreate.id || nanoid() }; + await putPluginConfig.mutateAsync(dataWithId); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/plugin_configs/add' }); + + const navigateToDetail = (res: APISIXType['RespPluginConfigDetail']) => + navigate({ + to: '/plugin_configs/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('pluginConfigs.singular') })} (JSON)` + : t('info.add.title', { name: t('pluginConfigs.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/plugin_configs/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/plugin_configs/detail.$id.tsx b/src/routes/plugin_configs/detail.$id.tsx index b85a574f1c..f84dad2fa8 100644 --- a/src/routes/plugin_configs/detail.$id.tsx +++ b/src/routes/plugin_configs/detail.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getPluginConfigQueryOptions } from '@/apis/hooks'; import { putPluginConfigReq } from '@/apis/plugin_configs'; @@ -35,25 +37,76 @@ import { FormPartPluginConfig } from '@/components/form-slice/FormPartPluginConf import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_PLUGIN_CONFIGS } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { + setEditMode: (mode: EditMode) => void; id: string; - readOnly: boolean; - setReadOnly: (v: boolean) => void; + onDeleteSuccess: () => void; +}; + +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); }; +/** + * Form Edit Component - Always editable + */ const PluginConfigDetailForm = (props: Props) => { - const { id, readOnly, setReadOnly } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); const pluginConfigQuery = useSuspenseQuery(getPluginConfigQueryOptions(id)); - const { data } = pluginConfigQuery; - const initialValue = data.value; + const { data, isLoading, refetch } = pluginConfigQuery; + + const form = useForm({ + resolver: zodResolver(APISIX.PluginConfigPut), + shouldUnregister: true, + shouldFocusError: true, + mode: 'all', + }); + + useEffect(() => { + if (data?.value && !isLoading) { + form.reset(data.value); + } + }, [data, form, isLoading]); const putPluginConfig = useMutation({ mutationFn: (d: APISIXType['PluginConfigPut']) => @@ -63,87 +116,212 @@ const PluginConfigDetailForm = (props: Props) => { message: t('info.edit.success', { name: t('pluginConfigs.singular') }), color: 'green', }); - pluginConfigQuery.refetch(); - setReadOnly(true); + await refetch(); }, }); - const form = useForm({ - resolver: zodResolver(APISIX.PluginConfigPut), - shouldUnregister: true, - shouldFocusError: true, - mode: 'all', - disabled: readOnly, - }); - - // Reset form when initialValue changes - useEffect(() => { - form.reset(initialValue); - }, [form, initialValue]); - - if (!data) return ; + if (isLoading || !data) { + return ; + } return ( - -
putPluginConfig.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putPluginConfig.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { - const { id } = useParams({ from: '/plugin_configs/detail/$id' }); +/** + * JSON Edit Component - Always editable + */ +const PluginConfigDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const pluginConfigQuery = useSuspenseQuery(getPluginConfigQueryOptions(id)); + const { data, isLoading, refetch } = pluginConfigQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (data?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = data.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [data, isLoading]); + + const putPluginConfig = useMutation({ + mutationFn: (d: APISIXType['PluginConfigPut']) => putPluginConfigReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('pluginConfigs.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putPluginConfig.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/plugin_configs' }); + }; + + if (isLoading || !data) { + return ; + } + return ( <> - - navigate({ to: '/plugin_configs' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('pluginConfigs.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type PluginConfigDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const PluginConfigDetail = (props: PluginConfigDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/plugin_configs/detail/$id' }); + const { mode } = useSearch({ from: '/plugin_configs/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/plugin_configs' })} + /> + ); } export const Route = createFileRoute('/plugin_configs/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/plugin_configs/index.tsx b/src/routes/plugin_configs/index.tsx index bccb89848f..a177af204f 100644 --- a/src/routes/plugin_configs/index.tsx +++ b/src/routes/plugin_configs/index.tsx @@ -16,23 +16,66 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, Group, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getPluginConfigListQueryOptions, usePluginConfigList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getPluginConfigListQueryOptions, getPluginConfigQueryOptions, usePluginConfigList } from '@/apis/hooks'; +import { putPluginConfigReq } from '@/apis/plugin_configs'; +import { FormPartPluginConfig } from '@/components/form-slice/FormPartPluginConfig'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_PLUGIN_CONFIGS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; -function PluginConfigsList() { +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['PluginConfigPut'] => { + return data as APISIXType['PluginConfigPut']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['PluginConfigPut']): APISIXType['PluginConfigPut'] => { + return pipeProduce()(formData) as APISIXType['PluginConfigPut']; +}; + +function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = usePluginConfigList(); + const { data, isLoading, refetch, pagination, setParams } = usePluginConfigList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< ProColumns[] @@ -49,16 +92,38 @@ function PluginConfigsList() { title: t('form.basic.name'), key: 'name', valueType: 'text', + ellipsis: true, }, { dataIndex: ['value', 'desc'], title: t('form.basic.desc'), key: 'desc', valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'plugins'], + title: t('form.plugins.label'), + key: 'plugins', + render: (_, record) => { + const plugins = record.value.plugins; + if (!plugins || Object.keys(plugins).length === 0) return '-'; + const pluginNames = Object.keys(plugins); + return ( + + {pluginNames.slice(0, 3).map((name) => ( + {name} + ))} + {pluginNames.length > 3 && ( + +{pluginNames.length - 3} + )} + + ); + }, }, { dataIndex: ['value', 'update_time'], - title: t('form.info.update_time'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', sorter: true, @@ -71,66 +136,99 @@ function PluginConfigsList() { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [refetch, t]); + }, [refetch, t, handleFormEdit, handleJsonEdit]); - return ( - - - ), - }, - ], - }, - }} - /> - - ); -} - -function RouteComponent() { - const { t } = useTranslation(); return ( <> - + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('pluginConfigs.singular')} + queryOptions={getPluginConfigQueryOptions(selectedId)} + schema={APISIX.PluginConfigPut} + toFormValues={toFormValues} + toApiData={(d) => toApiData({ ...d, id: selectedId })} + onSave={(data) => putPluginConfigReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['pluginConfigs'] })} + > + + + + + putPluginConfigReq(req, data as APISIXType['PluginConfigPut'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['pluginConfigs'] })} + /> + + )} ); } diff --git a/src/routes/protos/add.tsx b/src/routes/protos/add.tsx index 798dac75f4..d60ccfe8b2 100644 --- a/src/routes/protos/add.tsx +++ b/src/routes/protos/add.tsx @@ -15,30 +15,77 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; import { createFileRoute, - useRouter as useReactRouter, + useNavigate, + useRouter, + useSearch, } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { postProtoReq } from '@/apis/protos'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartProto } from '@/components/form-slice/FormPartProto'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; import { APISIXProtos } from '@/types/schema/apisix/protos'; +import IconCode from '~icons/material-symbols/code'; + +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Proto creation template +const PROTO_TEMPLATE = { + content: '', +}; const defaultValues: APISIXType['ProtoPost'] = { content: '', }; -const ProtoAddForm = () => { +type Props = { + onSuccess: () => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { const { t } = useTranslation(); - const router = useReactRouter(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + setPreviewJson(JSON.stringify(formData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const ProtoAddForm = (props: Props) => { + const { onSuccess } = props; + const { t } = useTranslation(); + const router = useRouter(); const postProto = useMutation({ mutationFn: (d: APISIXType['ProtoPost']) => postProtoReq(req, d), @@ -47,7 +94,7 @@ const ProtoAddForm = () => { message: t('info.add.success', { name: t('protos.singular') }), color: 'green', }); - await router.navigate({ to: '/protos' }); + await onSuccess(); }, }); @@ -63,22 +110,98 @@ const ProtoAddForm = () => {
postProto.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const ProtoAddJSON = (props: Props) => { + const { onSuccess } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(PROTO_TEMPLATE, null, 2) + ); + + const postProto = useMutation({ + mutationFn: (d: APISIXType['ProtoPost']) => postProtoReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.add.success', { name: t('protos.singular') }), + color: 'green', + }); + await onSuccess(); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postProto.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/protos/add' }); + + const handleSuccess = () => navigate({ to: '/protos' }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('protos.singular') })} (JSON)` + : t('info.add.title', { name: t('protos.singular') }); + return ( <> - - + + {isJsonMode ? ( + + ) : ( + + )} ); } export const Route = createFileRoute('/protos/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/protos/detail.$id.tsx b/src/routes/protos/detail.$id.tsx index c19d184d75..cf1557effc 100644 --- a/src/routes/protos/detail.$id.tsx +++ b/src/routes/protos/detail.$id.tsx @@ -16,17 +16,19 @@ */ import { zodResolver } from '@hookform/resolvers/zod'; import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getProtoQueryOptions } from '@/apis/hooks'; import { putProtoReq } from '@/apis/protos'; @@ -35,20 +37,61 @@ import { FormPartProto } from '@/components/form-slice/FormPartProto'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_PROTOS } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; -type ProtoFormProps = { +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; + +type Props = { + setEditMode: (mode: EditMode) => void; id: string; - readOnly: boolean; - setReadOnly: (v: boolean) => void; + onDeleteSuccess: () => void; +}; + +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); }; -const ProtoDetailForm = ({ id, readOnly, setReadOnly }: ProtoFormProps) => { +/** + * Form Edit Component - Always editable + */ +const ProtoDetailForm = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: protoData, isLoading, @@ -59,9 +102,14 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }: ProtoFormProps) => { resolver: zodResolver(APISIX.Proto), shouldUnregister: true, mode: 'all', - disabled: readOnly, }); + useEffect(() => { + if (protoData?.value && !isLoading) { + form.reset(protoData.value); + } + }, [protoData, form, isLoading]); + const putProto = useMutation({ mutationFn: (d: APISIXType['Proto']) => putProtoReq(req, pipeProduce()(d)), async onSuccess() { @@ -70,82 +118,214 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }: ProtoFormProps) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); - // Update form values when data is loaded - useEffect(() => { - if (protoData?.value) { - form.reset(protoData.value); - } - }, [protoData, form]); - if (isLoading) { return ; } return ( - -
putProto.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putProto.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { - const { id } = useParams({ from: '/protos/detail/$id' }); +/** + * JSON Edit Component - Always editable + */ +const ProtoDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const { + data: protoData, + isLoading, + refetch, + } = useSuspenseQuery(getProtoQueryOptions(id)); + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (protoData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = protoData.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [protoData, isLoading]); + + const putProto = useMutation({ + mutationFn: (d: APISIXType['Proto']) => putProtoReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('protos.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putProto.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/protos' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/protos' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('protos.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type ProtoDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const ProtoDetail = (props: ProtoDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/protos/detail/$id' }); + const { mode } = useSearch({ from: '/protos/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/protos' })} + /> + ); } export const Route = createFileRoute('/protos/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/protos/index.tsx b/src/routes/protos/index.tsx index 2754954980..ac4e2499d6 100644 --- a/src/routes/protos/index.tsx +++ b/src/routes/protos/index.tsx @@ -16,24 +16,66 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { CloseButton, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getProtoListQueryOptions, useProtoList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getProtoListQueryOptions, getProtoQueryOptions, useProtoList } from '@/apis/hooks'; +import { putProtoReq } from '@/apis/protos'; +import { FormPartProto } from '@/components/form-slice/FormPartProto'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_PROTOS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; + +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['Proto'] => { + return data as APISIXType['Proto']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['Proto']): APISIXType['Proto'] => { + return pipeProduce()(formData) as APISIXType['Proto']; +}; function RouteComponent() { const { t } = useTranslation(); + const { data, isLoading, refetch, pagination, setParams } = useProtoList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; - const { data, isLoading, refetch, pagination } = useProtoList(); + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< ProColumns[] @@ -45,28 +87,42 @@ function RouteComponent() { key: 'id', valueType: 'text', }, + { + dataIndex: ['value', 'desc'], + title: t('form.basic.desc'), + key: 'desc', + valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, + }, { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [t, refetch]); + }, [t, refetch, handleFormEdit, handleJsonEdit]); return ( <> @@ -78,18 +134,34 @@ function RouteComponent() { rowKey="id" loading={isLoading} search={false} - options={false} + options={{ + reload: () => refetch(), + density: true, + setting: true, + }} pagination={pagination} cardProps={{ bodyStyle: { padding: 0 } }} toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), menu: { type: 'inline', items: [ { key: 'add', label: ( - + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('protos.singular')} + queryOptions={getProtoQueryOptions(selectedId)} + schema={APISIX.Proto} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putProtoReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['protos'] })} + > + + + + + putProtoReq(req, data as APISIXType['Proto'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['protos'] })} + /> + + )} ); } diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx index ac26e94da0..f8febfd0ab 100644 --- a/src/routes/routes/add.tsx +++ b/src/routes/routes/add.tsx @@ -15,13 +15,17 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { createFileRoute, useNavigate, useRouter , useSearch } from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; -import { postRouteReq } from '@/apis/routes'; +import { postRouteRawReq, postRouteReq } from '@/apis/routes'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; import { @@ -30,18 +34,86 @@ import { } from '@/components/form-slice/FormPartRoute/schema'; import { produceRoute } from '@/components/form-slice/FormPartRoute/util'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; + +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Route creation template with minimum required fields (JSON format) +const ROUTE_TEMPLATE = { + name: '', + uri: '/*', + methods: ['GET', 'POST', 'PUT', 'DELETE'], + upstream: { + type: 'roundrobin', + nodes: { + 'httpbin.org:80': 1, + }, + }, +}; + +// Form default values (array format for nodes) +const FORM_DEFAULT_VALUES: Partial = { + uri: '/*', + methods: ['GET', 'POST', 'PUT', 'DELETE'], + upstream: { + type: 'roundrobin', + nodes: [ + { + host: 'httpbin.org', + port: 80, + weight: 1, + priority: 0, + }, + ], + }, +}; type Props = { navigate: (res: APISIXType['RespRouteDetail']) => Promise; defaultValues?: Partial; }; +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + // Transform form data to API format for preview + const apiData = produceRoute(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + export const RouteAddForm = (props: Props) => { const { navigate, defaultValues } = props; const { t } = useTranslation(); + const router = useRouter(); const postRoute = useMutation({ mutationFn: (d: RoutePostType) => postRouteReq(req, produceRoute(d)), @@ -66,32 +138,108 @@ export const RouteAddForm = (props: Props) => {
postRoute.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const RouteAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(ROUTE_TEMPLATE, null, 2) + ); + + const postRoute = useMutation({ + mutationFn: ( + d: Omit + ) => postRouteRawReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('routes.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // Remove fields that should not be sent on create + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postRoute.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); const navigate = useNavigate(); + const { mode } = useSearch({ from: '/routes/add' }); + + const navigateToDetail = (res: APISIXType['RespRouteDetail']) => + navigate({ + to: '/routes/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('routes.singular') })} (JSON)` + : t('info.add.title', { name: t('routes.singular') }); + return ( <> - - - - navigate({ - to: '/routes/detail/$id', - params: { id: res.data.value.id }, - }) - } - /> - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/routes/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx index ca49bf2de7..20b7289559 100644 --- a/src/routes/routes/detail.$id.tsx +++ b/src/routes/routes/detail.$id.tsx @@ -16,17 +16,19 @@ */ import { zodResolver } from '@hookform/resolvers/zod'; import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getRouteQueryOptions } from '@/apis/hooks'; import { putRouteReq } from '@/apis/routes'; @@ -44,20 +46,60 @@ import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/ import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; -import { type APISIXType } from '@/types/schema/apisix'; +import type { APISIXType } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Simplified: only 'form' and 'json' modes, both editable +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; id: string; + onDeleteSuccess: () => void; }; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = produceRoute(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ const RouteDetailForm = (props: Props) => { - const { readOnly, setReadOnly, id } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); const routeQuery = useQuery(getRouteQueryOptions(id)); const { data: routeData, isLoading, refetch } = routeQuery; @@ -67,7 +109,6 @@ const RouteDetailForm = (props: Props) => { shouldUnregister: true, shouldFocusError: true, mode: 'all', - disabled: readOnly, }); useEffect(() => { @@ -89,7 +130,6 @@ const RouteDetailForm = (props: Props) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); @@ -98,76 +138,205 @@ const RouteDetailForm = (props: Props) => { } return ( - -
putRoute.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putRoute.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -type RouteDetailProps = Pick & { - onDeleteSuccess: () => void; -}; -export const RouteDetail = (props: RouteDetailProps) => { - const { id, onDeleteSuccess } = props; +/** + * JSON Edit Component - Always editable + */ +const RouteDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); + const navigate = useNavigate(); + + const routeQuery = useQuery(getRouteQueryOptions(id)); + const { data: routeData, isLoading, refetch } = routeQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (routeData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = routeData.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [routeData, isLoading]); + + const putRoute = useMutation({ + mutationFn: (d: APISIXType['Route']) => putRouteReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('routes.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putRoute.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/routes' }); + }; + + if (isLoading) { + return ; + } return ( <> - - - - ), - })} + title={`${t('info.edit.title', { name: t('routes.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); }; +type RouteDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const RouteDetail = (props: RouteDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + // Sync editMode with initialMode when URL param changes + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + function RouteComponent() { const { id } = useParams({ from: '/routes/detail/$id' }); + const { mode } = useSearch({ from: '/routes/detail/$id' }); const navigate = useNavigate(); + + // Direct mapping: URL mode param to edit mode + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + return ( - navigate({ to: '/routes' })} /> + navigate({ to: '/routes' })} + /> ); } export const Route = createFileRoute('/routes/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 6b44fbd776..89ff30811f 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -14,135 +14,110 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { ProColumns } from '@ant-design/pro-components'; -import { ProTable } from '@ant-design/pro-components'; +import { useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; -import type { WithServiceIdFilter } from '@/apis/routes'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getRouteListQueryOptions, getRouteQueryOptions } from '@/apis/hooks'; +import { putRouteReq } from '@/apis/routes'; +import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; +import { RoutePutSchema, type RoutePutType } from '@/components/form-slice/FormPartRoute/schema'; +import { produceRoute, produceVarsToForm } from '@/components/form-slice/FormPartRoute/util'; +import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; -import { AntdConfigProvider } from '@/config/antdConfigProvider'; +import { RouteList } from '@/components/page/RouteList'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { API_ROUTES } from '@/config/constant'; import { queryClient } from '@/config/global'; +import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; -import type { ListPageKeys } from '@/utils/useTablePagination'; -export type RouteListProps = { - routeKey: Extract; - defaultParams?: Partial; - ToDetailBtn: (props: { - record: APISIXType['RespRouteItem']; - }) => React.ReactNode; -}; - -export const RouteList = (props: RouteListProps) => { - const { routeKey, ToDetailBtn, defaultParams } = props; - const { data, isLoading, refetch, pagination } = useRouteList( - routeKey, - defaultParams +// Transform API data to form values +const toFormValues = (data: Record): RoutePutType => { + const upstreamProduced = produceToUpstreamForm( + (data.upstream as Record) || {}, + data ); - const { t } = useTranslation(); - - const columns = useMemo[]>(() => { - return [ - { - dataIndex: ['value', 'id'], - title: 'ID', - key: 'id', - valueType: 'text', - }, - { - dataIndex: ['value', 'name'], - title: t('form.basic.name'), - key: 'name', - valueType: 'text', - }, - { - dataIndex: ['value', 'desc'], - title: t('form.basic.desc'), - key: 'desc', - valueType: 'text', - }, - { - dataIndex: ['value', 'uri'], - title: 'URI', - key: 'uri', - valueType: 'text', - }, - { - title: t('table.actions'), - valueType: 'option', - key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], - }, - ]; - }, [t, ToDetailBtn, refetch]); + return produceVarsToForm(upstreamProduced); +}; - return ( - - - ), - }, - ], - }, - }} - /> - - ); +// Transform form values to API data +const toApiData = (formData: RoutePutType): APISIXType['Route'] => { + return produceRoute(formData) as APISIXType['Route']; }; function RouteComponent() { const { t } = useTranslation(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedRouteId, setSelectedRouteId] = useState(null); + + const handleFormEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openFormDrawer(); + }; + + const handleJsonEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openJsonDrawer(); + }; + return ( <> ( - ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} /> )} + AddButton={ + + } /> + + {selectedRouteId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('routes.singular')} + queryOptions={getRouteQueryOptions(selectedRouteId)} + schema={RoutePutSchema} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putRouteReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['routes'] })} + > + + + + + putRouteReq(req, data as APISIXType['Route'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['routes'] })} + /> + + )} ); } diff --git a/src/routes/secrets/add.tsx b/src/routes/secrets/add.tsx index ee62710b1b..1cd12cd5b2 100644 --- a/src/routes/secrets/add.tsx +++ b/src/routes/secrets/add.tsx @@ -15,25 +15,79 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; import { nanoid } from 'nanoid'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { putSecretReq } from '@/apis/secrets'; import { 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 { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { queryClient } from '@/config/global'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; -const SecretAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Secret creation template +const SECRET_TEMPLATE = { + manager: 'vault', + uri: 'http://127.0.0.1:8200', + prefix: '/kv/apisix', + token: '', +}; + +type Props = { + onSuccess: () => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const SecretAddForm = (props: Props) => { + const { onSuccess } = props; const { t } = useTranslation(); const router = useRouter(); @@ -45,11 +99,8 @@ const SecretAddForm = () => { message: t('info.add.success', { name: t('secrets.singular') }), color: 'green', }); - // Invalidate secrets list query to refetch fresh data await queryClient.invalidateQueries({ queryKey: ['secrets'] }); - await router.navigate({ - to: '/secrets', - }); + await onSuccess(); }, }); @@ -69,26 +120,103 @@ const SecretAddForm = () => {
putSecret.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + + ); }; +const SecretAddJSON = (props: Props) => { + const { onSuccess } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(SECRET_TEMPLATE, null, 2) + ); + + const putSecret = useMutation({ + mutationFn: (d: APISIXType['Secret']) => putSecretReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.add.success', { name: t('secrets.singular') }), + color: 'green', + }); + await queryClient.invalidateQueries({ queryKey: ['secrets'] }); + await onSuccess(); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + // Auto-generate id if not provided + const dataWithId = { ...dataToCreate, id: dataToCreate.id || nanoid() }; + await putSecret.mutateAsync(dataWithId); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/secrets/add' }); + + const handleSuccess = () => navigate({ to: '/secrets' }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('secrets.singular') })} (JSON)` + : t('info.add.title', { name: t('secrets.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/secrets/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/secrets/detail.$manager.$id.tsx b/src/routes/secrets/detail.$manager.$id.tsx index 9c2e96599f..ee05a26a19 100644 --- a/src/routes/secrets/detail.$manager.$id.tsx +++ b/src/routes/secrets/detail.$manager.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getSecretQueryOptions } from '@/apis/hooks'; import { putSecretReq } from '@/apis/secrets'; @@ -35,28 +37,63 @@ import { FormPartSecret } from '@/components/form-slice/FormPartSecret'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_SECRETS } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + manager: APISIXType['Secret']['manager']; + id: string; + onDeleteSuccess: () => void; }; -const SecretDetailForm = (props: Props) => { - const { readOnly, setReadOnly } = props; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { const { t } = useTranslation(); - const { manager, id } = useParams({ from: '/secrets/detail/$manager/$id' }); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; - const secretQuery = useQuery( - getSecretQueryOptions({ - id, - manager: manager as APISIXType['Secret']['manager'], - }) + return ( + <> + + + ); +}; + +/** + * Form Edit Component - Always editable + */ +const SecretDetailForm = (props: Props) => { + const { setEditMode, manager, id, onDeleteSuccess } = props; + const { t } = useTranslation(); + const navigate = useNavigate(); + + const secretQuery = useQuery(getSecretQueryOptions({ id, manager })); const { data: secretData, isLoading, refetch } = secretQuery; const form = useForm({ @@ -64,15 +101,13 @@ const SecretDetailForm = (props: Props) => { shouldUnregister: true, shouldFocusError: true, mode: 'all', - disabled: readOnly, }); useEffect(() => { if (secretData?.value && !isLoading) { form.reset(secretData.value); } - // readonly is used as a dep to ensure that it can be reset correctly when switching states. - }, [secretData, form, isLoading, readOnly]); + }, [secretData, form, isLoading]); const putSecret = useMutation({ mutationFn: (d: APISIXType['Secret']) => @@ -83,7 +118,6 @@ const SecretDetailForm = (props: Props) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); @@ -92,62 +126,211 @@ const SecretDetailForm = (props: Props) => { } return ( - -
putSecret.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putSecret.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { +/** + * JSON Edit Component - Always editable + */ +const SecretDetailJSON = (props: Props) => { + const { setEditMode, manager, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); - const { manager, id } = useParams({ from: '/secrets/detail/$manager/$id' }); const navigate = useNavigate(); + const secretQuery = useQuery(getSecretQueryOptions({ id, manager })); + const { data: secretData, isLoading, refetch } = secretQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (secretData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = + secretData.value as APISIXType['Secret'] & { + create_time?: number; + update_time?: number; + }; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [secretData, isLoading]); + + const putSecret = useMutation({ + mutationFn: (d: APISIXType['Secret']) => putSecretReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('secrets.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putSecret.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/secrets' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/secrets' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('secrets.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type SecretDetailProps = { + manager: APISIXType['Secret']['manager']; + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const SecretDetail = (props: SecretDetailProps) => { + const { manager, id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { manager, id } = useParams({ from: '/secrets/detail/$manager/$id' }); + const { mode } = useSearch({ from: '/secrets/detail/$manager/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/secrets' })} + /> + ); } export const Route = createFileRoute('/secrets/detail/$manager/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/secrets/index.tsx b/src/routes/secrets/index.tsx index fa9810c34b..3eff0db64d 100644 --- a/src/routes/secrets/index.tsx +++ b/src/routes/secrets/index.tsx @@ -16,23 +16,71 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { CloseButton, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSecretListQueryOptions, useSecretList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getSecretListQueryOptions, getSecretQueryOptions, useSecretList } from '@/apis/hooks'; +import { putSecretReq } from '@/apis/secrets'; +import { FormPartSecret } from '@/components/form-slice/FormPartSecret'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_SECRETS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; -function SecretList() { +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['Secret'] => { + return data as APISIXType['Secret']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['Secret']): APISIXType['Secret'] => { + return pipeProduce()(formData) as APISIXType['Secret']; +}; + +type SelectedSecret = { + id: string; + manager: APISIXType['Secret']['manager']; +}; + +function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useSecretList(); + const { data, isLoading, refetch, pagination, setParams } = useSecretList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedSecret, setSelectedSecret] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string, manager: APISIXType['Secret']['manager']) => { + setSelectedSecret({ id, manager }); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string, manager: APISIXType['Secret']['manager']) => { + setSelectedSecret({ id, manager }); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< ProColumns[] @@ -43,81 +91,120 @@ function SecretList() { title: 'ID', key: 'id', valueType: 'text', - width: 300, + ellipsis: true, }, { dataIndex: ['value', 'manager'], title: t('form.secrets.manager'), key: 'manager', valueType: 'text', - width: 120, + }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, }, { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id, record.value.manager)} + onJsonEdit={() => handleJsonEdit(record.value.id, record.value.manager)} + /> + ), }, ]; - }, [t, refetch]); - - return ( - - - ), - }, - ], - }, - }} - /> - - ); -} - -function RouteComponent() { - const { t } = useTranslation(); + }, [t, refetch, handleFormEdit, handleJsonEdit]); return ( <> - + + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + + {selectedSecret && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('secrets.singular')} + queryOptions={getSecretQueryOptions(selectedSecret)} + schema={APISIX.Secret} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putSecretReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['secrets'] })} + > + + + + + putSecretReq(req, data as APISIXType['Secret'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['secrets'] })} + /> + + )} ); } diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx index e6d7ec3ca1..403fbb6b06 100644 --- a/src/routes/services/add.tsx +++ b/src/routes/services/add.tsx @@ -15,23 +15,81 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { postServiceReq, type ServicePostType } from '@/apis/services'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartService } from '@/components/form-slice/FormPartService'; import { ServicePostSchema } from '@/components/form-slice/FormPartService/schema'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; -const ServiceAddForm = () => { +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Service creation template +const SERVICE_TEMPLATE = { + name: '', + upstream: { + type: 'roundrobin', + nodes: { + 'httpbin.org:80': 1, + }, + }, +}; + +type Props = { + navigate: (res: APISIXType['RespServiceDetail']) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const ServiceAddForm = (props: Props) => { + const { navigate } = props; const { t } = useTranslation(); const router = useRouter(); @@ -46,10 +104,7 @@ const ServiceAddForm = () => { message: t('info.add.success', { name: t('services.singular') }), color: 'green', }); - await router.navigate({ - to: '/services/detail/$id', - params: { id: res.data.value.id }, - }); + await navigate(res); }, }); @@ -64,26 +119,104 @@ const ServiceAddForm = () => {
postService.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const ServiceAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(SERVICE_TEMPLATE, null, 2) + ); + + const postService = useMutation({ + mutationFn: (d: Partial) => postServiceReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('services.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postService.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/services/add' }); + + const navigateToDetail = (res: APISIXType['RespServiceDetail']) => + navigate({ + to: '/services/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('services.singular') })} (JSON)` + : t('info.add.title', { name: t('services.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/services/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/services/detail.$id/index.tsx b/src/routes/services/detail.$id/index.tsx index e64f9188de..459d7a0d1a 100644 --- a/src/routes/services/detail.$id/index.tsx +++ b/src/routes/services/detail.$id/index.tsx @@ -16,17 +16,19 @@ */ import { zodResolver } from '@hookform/resolvers/zod'; import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getServiceQueryOptions } from '@/apis/hooks'; import { putServiceReq } from '@/apis/services'; @@ -35,22 +37,61 @@ import { FormPartService } from '@/components/form-slice/FormPartService'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_SERVICES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + id: string; + onDeleteSuccess: () => void; }; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ const ServiceDetailForm = (props: Props) => { - const { readOnly, setReadOnly } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const { id } = useParams({ from: '/services/detail/$id' }); + const navigate = useNavigate(); const serviceQuery = useSuspenseQuery(getServiceQueryOptions(id)); const { data: serviceData, isLoading, refetch } = serviceQuery; @@ -60,7 +101,6 @@ const ServiceDetailForm = (props: Props) => { shouldUnregister: true, shouldFocusError: true, mode: 'all', - disabled: readOnly, }); useEffect(() => { @@ -81,7 +121,6 @@ const ServiceDetailForm = (props: Props) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); @@ -90,62 +129,203 @@ const ServiceDetailForm = (props: Props) => { } return ( - -
putService.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putService.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -function RouteComponent() { +/** + * JSON Edit Component - Always editable + */ +const ServiceDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); - const { id } = useParams({ from: '/services/detail/$id' }); const navigate = useNavigate(); + const serviceQuery = useSuspenseQuery(getServiceQueryOptions(id)); + const { data: serviceData, isLoading, refetch } = serviceQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (serviceData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = serviceData.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [serviceData, isLoading]); + + const putService = useMutation({ + mutationFn: (d: APISIXType['Service']) => putServiceReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('services.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putService.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/services' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/services' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('services.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); +}; + +type ServiceDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const ServiceDetail = (props: ServiceDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/services/detail/$id' }); + const { mode } = useSearch({ from: '/services/detail/$id/' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/services' })} + /> + ); } export const Route = createFileRoute('/services/detail/$id/')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/services/detail.$id/routes/detail.$routeId.tsx b/src/routes/services/detail.$id/routes/detail.$routeId.tsx index bec09ac9fc..93b7982f2d 100644 --- a/src/routes/services/detail.$id/routes/detail.$routeId.tsx +++ b/src/routes/services/detail.$id/routes/detail.$routeId.tsx @@ -32,6 +32,7 @@ function RouteComponent() { navigate({ to: '/services/detail/$id/routes', diff --git a/src/routes/services/detail.$id/routes/index.tsx b/src/routes/services/detail.$id/routes/index.tsx index 97e092c629..c313f83d19 100644 --- a/src/routes/services/detail.$id/routes/index.tsx +++ b/src/routes/services/detail.$id/routes/index.tsx @@ -14,19 +14,60 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useDisclosure } from '@mantine/hooks'; import { createFileRoute, useParams } from '@tanstack/react-router'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getRouteListQueryOptions } from '@/apis/hooks'; +import { getRouteListQueryOptions, getRouteQueryOptions } from '@/apis/hooks'; +import { putRouteReq } from '@/apis/routes'; +import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; +import { RoutePutSchema, type RoutePutType } from '@/components/form-slice/FormPartRoute/schema'; +import { produceRoute, produceVarsToForm } from '@/components/form-slice/FormPartRoute/util'; +import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { RouteList } from '@/components/page/RouteList'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { API_ROUTES } from '@/config/constant'; import { queryClient } from '@/config/global'; -import { RouteList } from '@/routes/routes'; +import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +// Transform API data to form values +const toFormValues = (data: Record): RoutePutType => { + const upstreamProduced = produceToUpstreamForm( + (data.upstream as Record) || {}, + data + ); + return produceVarsToForm(upstreamProduced); +}; + +// Transform form values to API data +const toApiData = (formData: RoutePutType): APISIXType['Route'] => { + return produceRoute(formData) as APISIXType['Route']; +}; + function RouteComponent() { const { t } = useTranslation(); const { id } = useParams({ from: '/services/detail/$id/routes/' }); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedRouteId, setSelectedRouteId] = useState(null); + + const handleFormEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openFormDrawer(); + }; + + const handleJsonEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openJsonDrawer(); + }; + return ( <> @@ -37,14 +78,45 @@ function RouteComponent() { service_id: id, }, }} - ToDetailBtn={({ record }) => ( - ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} /> )} /> + + {selectedRouteId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('routes.singular')} + queryOptions={getRouteQueryOptions(selectedRouteId)} + schema={RoutePutSchema} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putRouteReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['routes'] })} + > + + + + + putRouteReq(req, data as APISIXType['Route'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['routes'] })} + /> + + )} ); } diff --git a/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx b/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx index 7809db51ca..094ed0897d 100644 --- a/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx +++ b/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx @@ -33,6 +33,7 @@ function RouteComponent() { navigate({ to: '/services/detail/$id/stream_routes', diff --git a/src/routes/services/detail.$id/stream_routes/index.tsx b/src/routes/services/detail.$id/stream_routes/index.tsx index 1b741ed0e8..1074460ba2 100644 --- a/src/routes/services/detail.$id/stream_routes/index.tsx +++ b/src/routes/services/detail.$id/stream_routes/index.tsx @@ -14,30 +14,70 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useDisclosure } from '@mantine/hooks'; import { createFileRoute, useParams } from '@tanstack/react-router'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getStreamRouteListQueryOptions } from '@/apis/hooks'; +import { getStreamRouteListQueryOptions, getStreamRouteQueryOptions } from '@/apis/hooks'; +import { putStreamRouteReq } from '@/apis/stream_routes'; +import { produceRoute } from '@/components/form-slice/FormPartRoute/util'; +import { FormPartStreamRoute } from '@/components/form-slice/FormPartStreamRoute'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { StreamRouteList } from '@/components/page/StreamRouteList'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; +import { API_STREAM_ROUTES } from '@/config/constant'; import { queryClient } from '@/config/global'; -import { StreamRouteList } from '@/routes/stream_routes'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { CommonFormContext } from '@/utils/form-context'; + +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['StreamRoute'] => { + return data as APISIXType['StreamRoute']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['StreamRoute']): APISIXType['StreamRoute'] => { + return produceRoute(formData) as APISIXType['StreamRoute']; +}; function StreamRouteComponent() { const { t } = useTranslation(); const { id } = useParams({ from: '/services/detail/$id/stream_routes/' }); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedRouteId, setSelectedRouteId] = useState(null); + + const handleFormEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openFormDrawer(); + }; + + const handleJsonEdit = (routeId: string) => { + setSelectedRouteId(routeId); + openJsonDrawer(); + }; + return ( <> ( - ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} /> )} defaultParams={{ @@ -45,7 +85,44 @@ function StreamRouteComponent() { service_id: id, }, }} + AddButton={ + + } /> + + {selectedRouteId && ( + <> + + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('streamRoutes.singular')} + queryOptions={getStreamRouteQueryOptions(selectedRouteId)} + schema={APISIX.StreamRoute} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putStreamRouteReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['streamRoutes'] })} + > + + + + + + putStreamRouteReq(req, data as APISIXType['StreamRoute'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['streamRoutes'] })} + /> + + )} ); } diff --git a/src/routes/services/index.tsx b/src/routes/services/index.tsx index 10fa740b22..4a6a4a63d9 100644 --- a/src/routes/services/index.tsx +++ b/src/routes/services/index.tsx @@ -16,23 +16,67 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getServiceListQueryOptions, useServiceList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getServiceListQueryOptions, getServiceQueryOptions, useServiceList } from '@/apis/hooks'; +import { putServiceReq } from '@/apis/services'; +import { FormPartService } from '@/components/form-slice/FormPartService'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn,ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_SERVICES } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; -const ServiceList = () => { - const { data, isLoading, refetch, pagination } = useServiceList(); +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['Service'] => { + return data as APISIXType['Service']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['Service']): APISIXType['Service'] => { + return pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(formData) as APISIXType['Service']; +}; + +function RouteComponent() { const { t } = useTranslation(); + const { data, isLoading, refetch, pagination, setParams } = useServiceList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo[]>(() => { return [ @@ -47,16 +91,30 @@ const ServiceList = () => { title: t('form.basic.name'), key: 'name', valueType: 'text', + ellipsis: true, }, { dataIndex: ['value', 'desc'], title: t('form.basic.desc'), key: 'desc', valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'upstream_id'], + title: t('form.upstreams.upstreamId'), + key: 'upstream_id', + render: (_, record) => { + const upstreamId = record.value.upstream_id; + const hasInlineUpstream = record.value.upstream && Object.keys(record.value.upstream).length > 0; + if (upstreamId) return {upstreamId}; + if (hasInlineUpstream) return {t('form.upstreams.inline')}; + return '-'; + }, }, { dataIndex: ['value', 'update_time'], - title: t('form.info.update_time'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', sorter: true, @@ -69,68 +127,99 @@ const ServiceList = () => { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [t, refetch]); - - return ( - - - ), - }, - ], - }, - }} - /> - - ); -}; + }, [t, refetch, handleFormEdit, handleJsonEdit]); -function RouteComponent() { - const { t } = useTranslation(); return ( <> - + refetch(), + density: true, + setting: true, + }} + pagination={pagination} + cardProps={{ bodyStyle: { padding: 0 } }} + toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), + menu: { + type: 'inline', + items: [ + { + key: 'add', + label: ( + + ), + }, + ], + }, + }} + /> + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('services.singular')} + queryOptions={getServiceQueryOptions(selectedId)} + schema={APISIX.Service} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putServiceReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['services'] })} + > + + + + + putServiceReq(req, data as APISIXType['Service'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['services'] })} + /> + + )} ); } diff --git a/src/routes/ssls/add.tsx b/src/routes/ssls/add.tsx index 7cbc1c0b04..cbe909fa23 100644 --- a/src/routes/ssls/add.tsx +++ b/src/routes/ssls/add.tsx @@ -15,11 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { postSSLReq } from '@/apis/ssls'; import { FormSubmitBtn } from '@/components/form/Btn'; @@ -29,14 +38,60 @@ import { type SSLPostType, } from '@/components/form-slice/FormPartSSL/schema'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { queryClient } from '@/config/global'; import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; + +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// SSL creation template +const SSL_TEMPLATE = { + snis: ['example.com'], + cert: '', + key: '', +}; + +type Props = { + onSuccess: () => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; -const SSLAddForm = () => { +const SSLAddForm = (props: Props) => { + const { onSuccess } = props; const { t } = useTranslation(); const router = useRouter(); + const postSSL = useMutation({ mutationFn: (d: SSLPostType) => postSSLReq(req, pipeProduce()(d)), async onSuccess() { @@ -44,11 +99,8 @@ const SSLAddForm = () => { message: t('info.add.success', { name: t('ssls.singular') }), color: 'green', }); - // Invalidate SSLs list query to refetch fresh data await queryClient.invalidateQueries({ queryKey: ['ssls'] }); - await router.navigate({ - to: '/ssls', - }); + await onSuccess(); }, }); @@ -62,24 +114,101 @@ const SSLAddForm = () => {
postSSL.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const SSLAddJSON = (props: Props) => { + const { onSuccess } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(SSL_TEMPLATE, null, 2) + ); + + const postSSL = useMutation({ + mutationFn: (d: Partial) => postSSLReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.add.success', { name: t('ssls.singular') }), + color: 'green', + }); + await queryClient.invalidateQueries({ queryKey: ['ssls'] }); + await onSuccess(); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postSSL.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/ssls/add' }); + + const handleSuccess = () => navigate({ to: '/ssls' }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('ssls.singular') })} (JSON)` + : t('info.add.title', { name: t('ssls.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/ssls/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx index ba5009c079..de853aba5f 100644 --- a/src/routes/ssls/detail.$id.tsx +++ b/src/routes/ssls/detail.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getSSLQueryOptions } from '@/apis/hooks'; import { putSSLReq } from '@/apis/ssls'; @@ -40,19 +42,61 @@ import { import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_SSLS } from '@/config/constant'; import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + id: string; + onDeleteSuccess: () => void; }; -const SSLDetailForm = (props: Props & { id: string }) => { - const { id, readOnly, setReadOnly } = props; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ +const SSLDetailForm = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: { value: sslData }, isLoading, @@ -63,9 +107,14 @@ const SSLDetailForm = (props: Props & { id: string }) => { resolver: zodResolver(SSLPutSchema), shouldUnregister: true, mode: 'all', - disabled: readOnly, }); + useEffect(() => { + if (sslData && !isLoading) { + form.reset(produceToSSLForm(sslData)); + } + }, [sslData, form, isLoading]); + const putSSL = useMutation({ mutationFn: (d: SSLPutType) => putSSLReq(req, pipeProduce()(d)), async onSuccess() { @@ -74,81 +123,214 @@ const SSLDetailForm = (props: Props & { id: string }) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); - useEffect(() => { - if (sslData && !isLoading) { - form.reset(produceToSSLForm(sslData)); - } - }, [sslData, form, isLoading]); - if (isLoading) { return ; } return ( - - -
- putSSL.mutateAsync(pipeProduce()(d)) - )} - > - - - {!readOnly && ( - - {t('form.btn.save')} - + <> + + + + + } + /> + + + putSSL.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + - )} - - - + +
+
+ ); }; -function RouteComponent() { +/** + * JSON Edit Component - Always editable + */ +const SSLDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const { id } = useParams({ from: '/ssls/detail/$id' }); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const { + data: { value: sslData }, + isLoading, + refetch, + } = useSuspenseQuery(getSSLQueryOptions(id)); + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (sslData && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = sslData; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [sslData, isLoading]); + + const putSSL = useMutation({ + mutationFn: (d: APISIXType['SSL']) => putSSLReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('ssls.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putSSL.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/ssls' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/ssls' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('ssls.singular') })} (JSON)`} + extra={ + + + + + } + /> + - ); +}; + +type SSLDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const SSLDetail = (props: SSLDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/ssls/detail/$id' }); + const { mode } = useSearch({ from: '/ssls/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/ssls' })} + /> + ); } export const Route = createFileRoute('/ssls/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/ssls/index.tsx b/src/routes/ssls/index.tsx index 9bc31f2073..0f093029dd 100644 --- a/src/routes/ssls/index.tsx +++ b/src/routes/ssls/index.tsx @@ -16,23 +16,66 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSSLListQueryOptions, useSSLList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getSSLListQueryOptions, getSSLQueryOptions, useSSLList } from '@/apis/hooks'; +import { putSSLReq } from '@/apis/ssls'; +import { FormPartSSL } from '@/components/form-slice/FormPartSSL'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_SSLS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; + +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['SSL'] => { + return data as APISIXType['SSL']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['SSL']): APISIXType['SSL'] => { + return pipeProduce()(formData) as APISIXType['SSL']; +}; function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useSSLList(); + const { data, isLoading, refetch, pagination, setParams } = useSSLList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo[]>(() => { return [ @@ -47,6 +90,7 @@ function RouteComponent() { title: 'SNI', key: 'sni', valueType: 'text', + ellipsis: true, render: (_, record) => { // Show sni if available, otherwise show the first snis entry const sni = record.value.sni; @@ -56,6 +100,39 @@ function RouteComponent() { return '-'; }, }, + { + dataIndex: ['value', 'type'], + title: t('form.ssls.type'), + key: 'type', + width: 100, + render: (_, record) => { + const type = record.value.type || 'server'; + return {type}; + }, + }, + { + dataIndex: ['value', 'validity_end'], + title: t('form.ssls.expiration'), + key: 'validity_end', + valueType: 'dateTime', + sorter: true, + render: (_, record) => { + const validityEnd = record.value.validity_end; + if (!validityEnd) return '-'; + const expDate = new Date(validityEnd * 1000); + const now = new Date(); + const daysUntilExpiry = Math.ceil((expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const isExpired = daysUntilExpiry < 0; + const isExpiringSoon = daysUntilExpiry >= 0 && daysUntilExpiry <= 30; + const color = isExpired ? 'red' : isExpiringSoon ? 'orange' : 'green'; + const text = isExpired + ? t('form.ssls.expired') + : daysUntilExpiry <= 30 + ? t('form.ssls.daysLeft', { days: daysUntilExpiry }) + : expDate.toLocaleDateString(); + return {text}; + }, + }, { dataIndex: ['value', 'status'], title: t('form.basic.status'), @@ -65,28 +142,35 @@ function RouteComponent() { 0: { text: t('table.disabled'), status: 'Error' }, }, }, + { + dataIndex: ['value', 'update_time'], + title: t('table.updateTime'), + key: 'update_time', + valueType: 'dateTime', + sorter: true, + renderText: (text) => { + if (!text) return '-'; + return new Date(Number(text) * 1000).toISOString(); + }, + }, { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [t, refetch]); + }, [t, refetch, handleFormEdit, handleJsonEdit]); return ( <> @@ -98,18 +182,34 @@ function RouteComponent() { rowKey="id" loading={isLoading} search={false} - options={false} + options={{ + reload: () => refetch(), + density: true, + setting: true, + }} pagination={pagination} cardProps={{ bodyStyle: { padding: 0 } }} toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), menu: { type: 'inline', items: [ { key: 'add', label: ( - @@ -120,6 +220,34 @@ function RouteComponent() { }} /> + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('ssls.singular')} + queryOptions={getSSLQueryOptions(selectedId)} + schema={APISIX.SSL} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putSSLReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['ssls'] })} + > + + + + + putSSLReq(req, data as APISIXType['SSL'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['ssls'] })} + /> + + )} ); } diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx index 39f02ebd7c..92aa8e6811 100644 --- a/src/routes/stream_routes/add.tsx +++ b/src/routes/stream_routes/add.tsx @@ -15,11 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { postStreamRouteReq } from '@/apis/stream_routes'; import { FormSubmitBtn } from '@/components/form/Btn'; @@ -30,19 +39,63 @@ import { type StreamRoutePostType, } from '@/components/form-slice/FormPartStreamRoute/schema'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; + +// Search params schema +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +// Stream Route creation template +const STREAM_ROUTE_TEMPLATE = { + server_port: 9100, + upstream: { + type: 'roundrobin', + nodes: { + '127.0.0.1:9000': 1, + }, + }, +}; type Props = { navigate: (res: APISIXType['RespStreamRouteDetail']) => Promise; defaultValues?: Partial; }; +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = produceRoute(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + export const StreamRouteAddForm = (props: Props) => { const { navigate, defaultValues } = props; const { t } = useTranslation(); + const router = useRouter(); const postStreamRoute = useMutation({ mutationFn: (d: StreamRoutePostType) => @@ -68,30 +121,100 @@ export const StreamRouteAddForm = (props: Props) => {
postStreamRoute.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const StreamRouteAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(STREAM_ROUTE_TEMPLATE, null, 2) + ); + + const postStreamRoute = useMutation({ + mutationFn: (d: Partial) => + postStreamRouteReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('streamRoutes.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postStreamRoute.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); const navigate = useNavigate(); + const { mode } = useSearch({ from: '/stream_routes/add' }); + + const navigateToDetail = (res: APISIXType['RespStreamRouteDetail']) => + navigate({ + to: '/stream_routes/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('streamRoutes.singular') })} (JSON)` + : t('info.add.title', { name: t('streamRoutes.singular') }); + return ( <> - - - - navigate({ - to: '/stream_routes/detail/$id', - params: { id: res.data.value.id }, - }) - } - /> - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } @@ -99,4 +222,5 @@ function RouteComponent() { export const Route = createFileRoute('/stream_routes/add')({ component: RouteComponent, errorComponent: StreamRoutesErrorComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 4abffa63d9..13376d1d21 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -15,18 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getStreamRouteQueryOptions } from '@/apis/hooks'; import { putStreamRouteReq } from '@/apis/stream_routes'; @@ -36,21 +38,60 @@ import { FormPartStreamRoute } from '@/components/form-slice/FormPartStreamRoute import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; id: string; + onDeleteSuccess: () => void; +}; + +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = produceRoute(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); }; +/** + * Form Edit Component - Always editable + */ const StreamRouteDetailForm = (props: Props) => { - const { readOnly, setReadOnly, id } = props; + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); const streamRouteQuery = useQuery(getStreamRouteQueryOptions(id)); const { data: streamRouteData, isLoading, refetch } = streamRouteQuery; @@ -60,7 +101,6 @@ const StreamRouteDetailForm = (props: Props) => { shouldUnregister: true, shouldFocusError: true, mode: 'all', - disabled: readOnly, }); useEffect(() => { @@ -78,7 +118,6 @@ const StreamRouteDetailForm = (props: Props) => { color: 'green', }); await refetch(); - setReadOnly(true); }, }); @@ -87,75 +126,197 @@ const StreamRouteDetailForm = (props: Props) => { } return ( - -
putStreamRoute.mutateAsync(d))}> - - - {!readOnly && ( + <> + - {t('form.btn.save')} - + - )} - -
+ } + /> + + +
putStreamRoute.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + + + +
+
+ ); }; -type StreamRouteDetailProps = Pick & { - onDeleteSuccess: () => void; -}; - -export const StreamRouteDetail = (props: StreamRouteDetailProps) => { - const { id, onDeleteSuccess } = props; +/** + * JSON Edit Component - Always editable + */ +const StreamRouteDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const [readOnly, setReadOnly] = useBoolean(true); + const navigate = useNavigate(); + + const streamRouteQuery = useQuery(getStreamRouteQueryOptions(id)); + const { data: streamRouteData, isLoading, refetch } = streamRouteQuery; + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (streamRouteData?.value && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = streamRouteData.value; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [streamRouteData, isLoading]); + + const putStreamRoute = useMutation({ + mutationFn: (d: APISIXType['StreamRoute']) => putStreamRouteReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('streamRoutes.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putStreamRoute.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/stream_routes' }); + }; + + if (isLoading) { + return ; + } return ( <> - - - - ), - })} + title={`${t('info.edit.title', { name: t('streamRoutes.singular') })} (JSON)`} + extra={ + + + + + } + /> + - - - ); }; +type StreamRouteDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const StreamRouteDetail = (props: StreamRouteDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + function RouteComponent() { const { id } = useParams({ from: '/stream_routes/detail/$id' }); + const { mode } = useSearch({ from: '/stream_routes/detail/$id' }); const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + return ( navigate({ to: '/stream_routes' })} /> ); @@ -164,4 +325,5 @@ function RouteComponent() { export const Route = createFileRoute('/stream_routes/detail/$id')({ component: RouteComponent, errorComponent: StreamRoutesErrorComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/stream_routes/index.tsx b/src/routes/stream_routes/index.tsx index 46d3613218..49ebd09275 100644 --- a/src/routes/stream_routes/index.tsx +++ b/src/routes/stream_routes/index.tsx @@ -14,142 +14,98 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { ProColumns } from '@ant-design/pro-components'; -import { ProTable } from '@ant-design/pro-components'; +import { useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getStreamRouteListQueryOptions, useStreamRouteList } from '@/apis/hooks'; -import type { WithServiceIdFilter } from '@/apis/routes'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getStreamRouteListQueryOptions, getStreamRouteQueryOptions } from '@/apis/hooks'; +import { putStreamRouteReq } from '@/apis/stream_routes'; +import { produceRoute } from '@/components/form-slice/FormPartRoute/util'; +import { FormPartStreamRoute } from '@/components/form-slice/FormPartStreamRoute'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { StreamRouteList } from '@/components/page/StreamRouteList'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; -import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_STREAM_ROUTES } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; -import type { ListPageKeys } from '@/utils/useTablePagination'; -export type StreamRouteListProps = { - routeKey: Extract< - ListPageKeys, - '/stream_routes/' | '/services/detail/$id/stream_routes/' - >; - ToDetailBtn: (props: { - record: APISIXType['RespStreamRouteItem']; - }) => React.ReactNode; - defaultParams?: Partial; +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['StreamRoute'] => { + return data as APISIXType['StreamRoute']; }; -export const StreamRouteList = (props: StreamRouteListProps) => { - const { routeKey, ToDetailBtn, defaultParams } = props; - const { data, isLoading, refetch, pagination } = useStreamRouteList( - routeKey, - defaultParams - ); - const { t } = useTranslation(); - - const columns = useMemo< - ProColumns[] - >(() => { - return [ - { - dataIndex: ['value', 'id'], - title: 'ID', - key: 'id', - valueType: 'text', - }, - { - dataIndex: ['value', 'server_addr'], - title: t('form.streamRoutes.serverAddr'), - key: 'server_addr', - valueType: 'text', - }, - { - dataIndex: ['value', 'server_port'], - title: t('form.streamRoutes.serverPort'), - key: 'server_port', - valueType: 'text', - }, - { - dataIndex: ['value', 'desc'], - title: t('form.basic.desc'), - key: 'desc', - valueType: 'text', - }, - { - title: t('table.actions'), - valueType: 'option', - key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], - }, - ]; - }, [t, ToDetailBtn, refetch]); - - return ( - - - ), - }, - ], - }, - }} - /> - - ); +// Transform form values to API data +const toApiData = (formData: APISIXType['StreamRoute']): APISIXType['StreamRoute'] => { + return produceRoute(formData) as APISIXType['StreamRoute']; }; function StreamRouteComponent() { const { t } = useTranslation(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + + const handleFormEdit = (id: string) => { + setSelectedId(id); + openFormDrawer(); + }; + + const handleJsonEdit = (id: string) => { + setSelectedId(id); + openJsonDrawer(); + }; return ( <> ( - ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} /> )} /> + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('streamRoutes.singular')} + queryOptions={getStreamRouteQueryOptions(selectedId)} + schema={APISIX.StreamRoute} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putStreamRouteReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['streamRoutes'] })} + > + + + + + putStreamRouteReq(req, data as APISIXType['StreamRoute'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['streamRoutes'] })} + /> + + )} ); } diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx index a080b815e4..20964d2756 100644 --- a/src/routes/upstreams/add.tsx +++ b/src/routes/upstreams/add.tsx @@ -15,21 +15,48 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useMutation } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { FormProvider, useForm } from 'react-hook-form'; +import { + createFileRoute, + useNavigate, + useRouter, + useSearch, +} from '@tanstack/react-router'; +import { useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { z } from 'zod'; +import { z as zod } from 'zod'; import { postUpstreamReq } from '@/apis/upstreams'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream'; import { FormPartUpstreamSchema } from '@/components/form-slice/FormPartUpstream/schema'; import { FormTOCBox } from '@/components/form-slice/FormSection'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { req } from '@/config/req'; +import type { APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; + +// Search params schema +const searchSchema = zod.object({ + mode: zod.enum(['form', 'json']).optional().default('form'), +}); + +// Upstream creation template with minimum required fields (JSON format) +const UPSTREAM_TEMPLATE = { + name: '', + type: 'roundrobin', + nodes: { + 'httpbin.org:80': 1, + }, +}; const PostUpstreamSchema = FormPartUpstreamSchema.omit({ id: true, @@ -37,22 +64,50 @@ const PostUpstreamSchema = FormPartUpstreamSchema.omit({ type PostUpstreamType = z.infer; -const UpstreamAddForm = () => { +type Props = { + navigate: (res: APISIXType['RespUpstreamDetail']) => Promise; +}; + +// Preview JSON button component (needs form context) +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +const UpstreamAddForm = (props: Props) => { + const { navigate } = props; const { t } = useTranslation(); const router = useRouter(); + const postUpstream = useMutation({ - mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, d), - async onSuccess(data) { + mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, pipeProduce()(d)), + async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('upstreams.singular') }), color: 'green', }); - await router.navigate({ - to: '/upstreams/detail/$id', - params: { id: data.data.value.id }, - }); + await navigate(res); }, }); + const form = useForm({ resolver: zodResolver(PostUpstreamSchema), shouldUnregister: true, @@ -61,32 +116,107 @@ const UpstreamAddForm = () => { return ( -
- postUpstream.mutateAsync(pipeProduce()(d)) - )} - > + postUpstream.mutateAsync(d))}> - {t('form.btn.add')} + + + + + {t('form.btn.save')} + +
); }; +const UpstreamAddJSON = (props: Props) => { + const { navigate } = props; + const { t } = useTranslation(); + const [jsonValue, setJsonValue] = useState( + JSON.stringify(UPSTREAM_TEMPLATE, null, 2) + ); + + const postUpstream = useMutation({ + mutationFn: (d: Partial) => postUpstreamReq(req, d), + async onSuccess(res) { + notifications.show({ + message: t('info.add.success', { name: t('upstreams.singular') }), + color: 'green', + }); + await navigate(res); + }, + onError(error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // Remove fields that should not be sent on create + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, create_time: _ct, update_time: _ut, ...dataToCreate } = parsed; + await postUpstream.mutateAsync(dataToCreate); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + return ( + + ); +}; + function RouteComponent() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { mode } = useSearch({ from: '/upstreams/add' }); + + const navigateToDetail = (res: APISIXType['RespUpstreamDetail']) => + navigate({ + to: '/upstreams/detail/$id', + params: { id: res.data.value.id }, + }); + + const isJsonMode = mode === 'json'; + const title = isJsonMode + ? `${t('info.add.title', { name: t('upstreams.singular') })} (JSON)` + : t('info.add.title', { name: t('upstreams.singular') }); + return ( <> - - - - + + {isJsonMode ? ( + + ) : ( + + + + )} ); } export const Route = createFileRoute('/upstreams/add')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/upstreams/detail.$id.tsx b/src/routes/upstreams/detail.$id.tsx index ca5f8e9c42..c6af4e5984 100644 --- a/src/routes/upstreams/detail.$id.tsx +++ b/src/routes/upstreams/detail.$id.tsx @@ -15,22 +15,20 @@ * limitations under the License. */ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Group,Skeleton } from '@mantine/core'; +import { Button, Group, Skeleton } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { - queryOptions, - useMutation, - useSuspenseQuery, -} from '@tanstack/react-query'; +import { queryOptions, useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router'; -import { useEffect } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useBoolean } from 'react-use'; +import { z } from 'zod'; import { getUpstreamReq, putUpstreamReq } from '@/apis/upstreams'; import { FormSubmitBtn } from '@/components/form/Btn'; @@ -40,15 +38,27 @@ import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/ import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { JSONEditorView } from '@/components/page/JSONEditorView'; import PageHeader from '@/components/page/PageHeader'; +import { PreviewJSONModal } from '@/components/page/PreviewJSONModal'; import { API_UPSTREAMS } from '@/config/constant'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; +import IconCode from '~icons/material-symbols/code'; +import IconForm from '~icons/material-symbols/list-alt'; + +// Search params schema for mode selection +const searchSchema = z.object({ + mode: z.enum(['form', 'json']).optional().default('form'), +}); + +type EditMode = 'form' | 'json'; type Props = { - readOnly: boolean; - setReadOnly: (v: boolean) => void; + setEditMode: (mode: EditMode) => void; + id: string; + onDeleteSuccess: () => void; }; const getUpstreamQueryOptions = (id: string) => @@ -57,11 +67,38 @@ const getUpstreamQueryOptions = (id: string) => queryFn: () => getUpstreamReq(req, id), }); -const UpstreamDetailForm = ( - props: Props & Pick -) => { - const { id, readOnly, setReadOnly } = props; +// Preview JSON button for Form mode +const PreviewJSONButton = () => { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { getValues } = useFormContext(); + const [previewJson, setPreviewJson] = useState('{}'); + + const handlePreview = () => { + const formData = getValues(); + const apiData = pipeProduce()(formData); + setPreviewJson(JSON.stringify(apiData, null, 2)); + open(); + }; + + return ( + <> + + + + ); +}; + +/** + * Form Edit Component - Always editable + */ +const UpstreamDetailForm = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: { value: upstreamData }, isLoading, @@ -72,96 +109,236 @@ const UpstreamDetailForm = ( resolver: zodResolver(FormPartUpstreamSchema), shouldUnregister: true, mode: 'all', - disabled: readOnly, }); + useEffect(() => { + if (upstreamData && !isLoading) { + form.reset(produceToUpstreamForm(upstreamData)); + } + }, [upstreamData, form, isLoading]); + const putUpstream = useMutation({ - mutationFn: (d: APISIXType['Upstream']) => putUpstreamReq(req, d), + mutationFn: (d: APISIXType['Upstream']) => + putUpstreamReq(req, pipeProduce()(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('upstreams.singular') }), color: 'green', }); await refetch(); - setReadOnly(true); }, }); - useEffect(() => { - if (upstreamData && !isLoading) { - form.reset(produceToUpstreamForm(upstreamData)); - } - }, [upstreamData, form, isLoading]); - if (isLoading) { return ; } return ( - - -
{ - putUpstream.mutateAsync(pipeProduce()(d)); - })} - > - - - {!readOnly && ( - - {t('form.btn.save')} - + <> + + + + + } + /> + + + putUpstream.mutateAsync(d))}> + + + + + + + + {t('form.btn.save')} + + - )} - - - + +
+
+ ); }; -function RouteComponent() { +/** + * JSON Edit Component - Always editable + */ +const UpstreamDetailJSON = (props: Props) => { + const { setEditMode, id, onDeleteSuccess } = props; const { t } = useTranslation(); - const { id } = useParams({ from: '/upstreams/detail/$id' }); - const [readOnly, setReadOnly] = useBoolean(true); const navigate = useNavigate(); + const { + data: { value: upstreamData }, + isLoading, + refetch, + } = useSuspenseQuery(getUpstreamQueryOptions(id)); + + const [jsonValue, setJsonValue] = useState('{}'); + + useEffect(() => { + if (upstreamData && !isLoading) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct, update_time: _ut, ...displayData } = + upstreamData as APISIXType['Upstream'] & { + create_time?: number; + update_time?: number; + }; + setJsonValue(JSON.stringify(displayData, null, 2)); + } + }, [upstreamData, isLoading]); + + const putUpstream = useMutation({ + mutationFn: (d: APISIXType['Upstream']) => putUpstreamReq(req, d), + async onSuccess() { + notifications.show({ + message: t('info.edit.success', { name: t('upstreams.singular') }), + color: 'green', + }); + await refetch(); + }, + onError(error) { + notifications.show({ + message: error.message || t('form.view.transformError'), + color: 'red', + }); + }, + }); + + const handleSave = async (): Promise => { + try { + const parsed = JSON.parse(jsonValue); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { create_time: _ct2, update_time: _ut2, ...dataToSave } = parsed; + await putUpstream.mutateAsync(dataToSave); + return true; + } catch (error) { + notifications.show({ + message: + error instanceof Error ? error.message : t('form.view.jsonParseError'), + color: 'red', + }); + return false; + } + }; + + const handleCancel = () => { + navigate({ to: '/upstreams' }); + }; + + if (isLoading) { + return ; + } + return ( <> - - navigate({ to: '/upstreams' })} - /> - - ), - })} + title={`${t('info.edit.title', { name: t('upstreams.singular') })} (JSON)`} + extra={ + + + + + } /> - ); +}; + +type UpstreamDetailProps = { + id: string; + onDeleteSuccess: () => void; + initialMode: EditMode; +}; + +export const UpstreamDetail = (props: UpstreamDetailProps) => { + const { id, onDeleteSuccess, initialMode } = props; + const [editMode, setEditMode] = useState(initialMode); + + // Sync editMode with initialMode when URL param changes + useEffect(() => { + setEditMode(initialMode); + }, [initialMode]); + + const isFormMode = editMode === 'form'; + + return isFormMode ? ( + + ) : ( + + ); +}; + +function RouteComponent() { + const { id } = useParams({ from: '/upstreams/detail/$id' }); + const { mode } = useSearch({ from: '/upstreams/detail/$id' }); + const navigate = useNavigate(); + + const initialMode: EditMode = mode === 'json' ? 'json' : 'form'; + + return ( + navigate({ to: '/upstreams' })} + /> + ); } export const Route = createFileRoute('/upstreams/detail/$id')({ component: RouteComponent, + validateSearch: searchSchema, }); diff --git a/src/routes/upstreams/index.tsx b/src/routes/upstreams/index.tsx index a1e6d55d0f..35f6d27258 100644 --- a/src/routes/upstreams/index.tsx +++ b/src/routes/upstreams/index.tsx @@ -16,23 +16,67 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { Badge, CloseButton, TextInput } from '@mantine/core'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getUpstreamListQueryOptions, useUpstreamList } from '@/apis/hooks'; -import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; +import { getUpstreamListQueryOptions, getUpstreamQueryOptions, useUpstreamList } from '@/apis/hooks'; +import { putUpstreamReq } from '@/apis/upstreams'; +import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream'; +import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util'; +import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; +import { FormEditDrawer } from '@/components/page/FormEditDrawer'; +import { JSONEditDrawer } from '@/components/page/JSONEditDrawer'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { TableActionMenu } from '@/components/page/TableActionMenu'; +import { ToAddPageDropdown } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_UPSTREAMS } from '@/config/constant'; import { queryClient } from '@/config/global'; -import type { APISIXType } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pipeProduce } from '@/utils/producer'; +import IconSearch from '~icons/material-symbols/search'; + +// Transform API data to form values +const toFormValues = (data: Record): APISIXType['Upstream'] => { + return produceToUpstreamForm(data as Partial) as APISIXType['Upstream']; +}; + +// Transform form values to API data +const toApiData = (formData: APISIXType['Upstream']): APISIXType['Upstream'] => { + return pipeProduce()(formData) as APISIXType['Upstream']; +}; function RouteComponent() { const { t } = useTranslation(); - const { data, isLoading, refetch, pagination } = useUpstreamList(); + const { data, isLoading, refetch, pagination, setParams } = useUpstreamList(); + const [formDrawerOpened, { open: openFormDrawer, close: closeFormDrawer }] = useDisclosure(false); + const [jsonDrawerOpened, { open: openJsonDrawer, close: closeJsonDrawer }] = useDisclosure(false); + const [selectedId, setSelectedId] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = useDebouncedCallback((value: string) => { + setParams({ name: value || undefined, page: 1 }); + }, 300); + + const handleClear = () => { + setSearchValue(''); + setParams({ name: undefined }); + }; + + const handleFormEdit = useCallback((id: string) => { + setSelectedId(id); + openFormDrawer(); + }, [openFormDrawer]); + + const handleJsonEdit = useCallback((id: string) => { + setSelectedId(id); + openJsonDrawer(); + }, [openJsonDrawer]); const columns = useMemo< ProColumns[] @@ -49,18 +93,43 @@ function RouteComponent() { title: t('form.basic.name'), key: 'name', valueType: 'text', + ellipsis: true, + }, + { + dataIndex: ['value', 'type'], + title: t('form.upstreams.type'), + key: 'type', + render: (_, record) => { + const type = record.value.type; + return type ? {type} : '-'; + }, }, { dataIndex: ['value', 'scheme'], title: t('form.upstreams.scheme'), key: 'scheme', - valueType: 'text', + render: (_, record) => { + const scheme = record.value.scheme; + return scheme ? {scheme} : '-'; + }, + }, + { + dataIndex: ['value', 'nodes'], + title: 'Nodes', + key: 'nodes', + render: (_, record) => { + const nodes = record.value.nodes; + if (!nodes) return '-'; + if (Array.isArray(nodes)) return nodes.length; + return Object.keys(nodes).length; + }, }, { dataIndex: ['value', 'update_time'], - title: t('form.upstreams.updateTime'), + title: t('table.updateTime'), key: 'update_time', valueType: 'dateTime', + sorter: true, renderText: (text) => { if (!text) return '-'; return new Date(Number(text) * 1000).toISOString(); @@ -70,24 +139,20 @@ function RouteComponent() { title: t('table.actions'), valueType: 'option', key: 'option', - width: 120, - render: (_, record) => [ - , - , - ], + width: 80, + render: (_, record) => ( + handleFormEdit(record.value.id)} + onJsonEdit={() => handleJsonEdit(record.value.id)} + /> + ), }, ]; - }, [t, refetch]); + }, [t, refetch, handleFormEdit, handleJsonEdit]); return ( <> @@ -99,18 +164,34 @@ function RouteComponent() { rowKey="id" loading={isLoading} search={false} - options={false} + options={{ + reload: () => refetch(), + density: true, + setting: true, + }} pagination={pagination} cardProps={{ bodyStyle: { padding: 0 } }} toolbar={{ + search: ( + } + rightSection={searchValue && } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + handleSearch(e.target.value); + }} + style={{ width: 250 }} + /> + ), menu: { type: 'inline', items: [ { key: 'add', label: ( - + + {selectedId && ( + <> + + opened={formDrawerOpened} + onClose={closeFormDrawer} + title={t('upstreams.singular')} + queryOptions={getUpstreamQueryOptions(selectedId)} + schema={APISIX.Upstream} + toFormValues={toFormValues} + toApiData={toApiData} + onSave={(data) => putUpstreamReq(req, data)} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['upstreams'] })} + > + + + + + putUpstreamReq(req, data as APISIXType['Upstream'])} + onSuccess={() => queryClient.invalidateQueries({ queryKey: ['upstreams'] })} + /> + + )} ); } diff --git a/src/styles/global.css b/src/styles/global.css index 2e63b257ef..9b4da456da 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,3 +1,30 @@ +/* Prevent antd tooltips and popovers from overflowing viewport and causing layout shift */ +.ant-tooltip, +.ant-popover { + max-width: calc(100vw - 20px); +} + +/* Prevent horizontal scrollbar from tooltips */ +html { + overflow-x: hidden; +} + +/* Ensure tooltips near right edge don't overflow */ +.ant-tooltip { + word-wrap: break-word; +} + +.ant-tooltip-placement-top, +.ant-tooltip-placement-topLeft, +.ant-tooltip-placement-topRight { + transform-origin: center bottom; +} + +.ant-popover-inner { + max-height: 80vh; + overflow-y: auto; +} + .monaco-editor, .monaco-editor .overflow-guard { border-radius: var(--mantine-radius-sm); @@ -9,15 +36,9 @@ left: 60px !important; } -.monaco-editor { - padding-bottom: 200px; -} - .editor-wrapper { border: 1px solid var(--mantine-color-gray-4); border-radius: var(--mantine-radius-sm); - position: relative; - height: 100%; &:focus-within { border-color: var(--mantine-color-blue-6); @@ -27,15 +48,14 @@ .editor-wrapper--disabled { background-color: var(--mantine-color-gray-0); border-color: var(--mantine-color-gray-3); - cursor: not-allowed !important; + cursor: default; &:focus-within { border-color: var(--mantine-color-gray-3); } & * { - cursor: not-allowed !important; - pointer-events: none !important; + cursor: default; } .monaco-editor, diff --git a/src/types/schema/apisix/ssls.ts b/src/types/schema/apisix/ssls.ts index c32d5fa901..44a99da7b5 100644 --- a/src/types/schema/apisix/ssls.ts +++ b/src/types/schema/apisix/ssls.ts @@ -44,6 +44,9 @@ const SSL = z type: SSLType.optional(), status: APISIXCommon.Status.optional(), ssl_protocols: z.array(SSLProtocols).optional(), + // Read-only fields populated by APISIX from certificate + validity_start: z.number().optional(), + validity_end: z.number().optional(), }) .partial() .merge(APISIXCommon.Basic) diff --git a/src/types/schema/apisix/type.ts b/src/types/schema/apisix/type.ts index edb860db3f..1ba7fb6605 100644 --- a/src/types/schema/apisix/type.ts +++ b/src/types/schema/apisix/type.ts @@ -41,7 +41,7 @@ export type APISIXType = RawAPISIXType & { RespStreamRouteList: AxiosResponse< APISIXListResponse >; - RespStreamRouteItem: APISIXType['RespRouteList']['data']['list'][number]; + RespStreamRouteItem: APISIXType['RespStreamRouteList']['data']['list'][number]; RespStreamRouteDetail: AxiosResponse< APISIXDetailResponse >; diff --git a/src/types/schema/apisix/upstreams.ts b/src/types/schema/apisix/upstreams.ts index 2a582a9ac6..5c3c17dcf1 100644 --- a/src/types/schema/apisix/upstreams.ts +++ b/src/types/schema/apisix/upstreams.ts @@ -58,8 +58,8 @@ const UpstreamPassHost = z.union([ const UpstreamNode = z.object({ host: z.string().min(1), - port: z.number().int().gte(1).lte(65535), - weight: z.number().int(), + port: z.number().int().gte(1).lte(65535).default(80), + weight: z.number().int().default(1), priority: z.number().int().optional(), }); diff --git a/src/types/schema/pageSearch.ts b/src/types/schema/pageSearch.ts index fb663566b3..8e476d8d45 100644 --- a/src/types/schema/pageSearch.ts +++ b/src/types/schema/pageSearch.ts @@ -29,8 +29,10 @@ export const pageSearchSchema = z .optional() .default(10) .transform((val) => (val ? Number(val) : 10)), + id: z.string().optional(), name: z.string().optional(), label: z.string().optional(), + uri: z.string().optional(), }) .passthrough(); diff --git a/src/utils/json-transformer.ts b/src/utils/json-transformer.ts new file mode 100644 index 0000000000..ebf06a628b --- /dev/null +++ b/src/utils/json-transformer.ts @@ -0,0 +1,99 @@ +/** + * 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. + */ + +/** + * JSON Transformer Utilities + * + * Provides bidirectional transformation between form state and JSON representation + * for APISIX Dashboard resources. + */ + +/** + * Convert form data to formatted JSON string + * + * @param formData - The form data object + * @param produceFn - Optional producer function to transform data before serialization + * @returns Formatted JSON string with 2-space indentation + */ +export const formToJSON = ( + formData: T, + produceFn?: (data: T) => unknown +): string => { + try { + // Apply producer function if provided (e.g., produceRoute) + const transformedData = produceFn ? produceFn(formData) : formData; + + // Convert to JSON with 2-space indentation for readability + return JSON.stringify(transformedData, null, 2); + } catch { + throw new Error('Failed to convert form data to JSON'); + } +}; + +/** + * Parse and validate JSON string, converting it to form data format + * + * @param jsonString - The JSON string to parse + * @param parseFormFn - Optional function to transform parsed data to form format + * @returns Parsed and transformed form data + * @throws Error if JSON is invalid or transformation fails + */ +export const jsonToForm = ( + jsonString: string, + parseFormFn?: (data: unknown) => T +): T => { + try { + // Parse JSON string + const parsed = JSON.parse(jsonString); + + // Apply form transformation if provided (e.g., produceVarsToForm) + const formData = parseFormFn ? parseFormFn(parsed) : parsed; + + return formData as T; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid JSON syntax'); + } + throw new Error('Failed to convert JSON to form data'); + } +}; + +/** + * Validate JSON syntax without parsing + * + * @param jsonString - The JSON string to validate + * @returns Object with isValid flag and optional error message + */ +export const validateJSONSyntax = ( + jsonString: string +): { isValid: boolean; error?: string } => { + try { + JSON.parse(jsonString); + return { isValid: true }; + } catch (error) { + if (error instanceof SyntaxError) { + return { + isValid: false, + error: error.message, + }; + } + return { + isValid: false, + error: 'Unknown JSON parsing error', + }; + } +}; diff --git a/src/utils/route-transformer.ts b/src/utils/route-transformer.ts new file mode 100644 index 0000000000..8181646f6d --- /dev/null +++ b/src/utils/route-transformer.ts @@ -0,0 +1,264 @@ +/** + * 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. + */ + +/** + * Route Transformer Module + * + * Provides symmetric (reversible) transformations between: + * - API format: What the server sends/receives + * - Internal format: Canonical representation (single source of truth) + * - Form format: What React Hook Form uses + * - JSON View format: What user sees in JSON editor + * + * Key principle: vars is ALWAYS an array in internal/API/JSON formats, + * but a JSON string in form format (for Monaco editor compatibility) + */ + +import { produce } from 'immer'; +import { isNotEmpty } from 'rambdax'; + +import type { RoutePutType } from '@/components/form-slice/FormPartRoute/schema'; +import type { APISIXType } from '@/types/schema/apisix'; + +import { deepCleanEmptyKeys, rmDoubleUnderscoreKeys } from './producer'; + +/** + * Internal canonical format for Route + * vars is always an array, never a string + */ +export type RouteInternal = Omit & { + vars?: unknown[]; + // Form-specific fields (prefixed with __) + __checksEnabled?: boolean; + __checksPassiveEnabled?: boolean; +}; + +/** + * Transform API data to internal format + * This is essentially an identity transformation since API already has vars as array + */ +export function apiToInternal(api: APISIXType['Route']): RouteInternal { + return { + ...api, + vars: api.vars as unknown[] | undefined, + }; +} + +/** + * Transform internal format to API format + * Removes form-specific fields (prefixed with __) and cleans empty keys + */ +export function internalToApi(internal: RouteInternal): APISIXType['Route'] { + return produce(internal, (draft) => { + // Remove __ prefixed fields + rmDoubleUnderscoreKeys(draft); + + // Remove timestamps (server manages these) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mutableDraft = draft as any; + delete mutableDraft.create_time; + delete mutableDraft.update_time; + + // Remove upstream if service_id or upstream_id is set + if ((draft.service_id || draft.upstream_id) && isNotEmpty(draft.upstream)) { + delete draft.upstream; + } + + // Clean empty keys + deepCleanEmptyKeys(draft); + }) as APISIXType['Route']; +} + +/** + * Transform internal format to form format + * Key change: vars array is stringified for Monaco editor + */ +export function internalToForm(internal: RouteInternal): RoutePutType { + // Deep clone to avoid mutation issues with frozen objects + const cloned = JSON.parse(JSON.stringify(internal)) as RoutePutType; + + // Stringify vars array for form editor + if (cloned.vars && Array.isArray(cloned.vars)) { + cloned.vars = JSON.stringify(cloned.vars); + } + + // Add form-specific upstream checks flags + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mutableCloned = cloned as any; + const upstream = internal.upstream; + if (upstream) { + mutableCloned.__checksEnabled = !!upstream.checks && isNotEmpty(upstream.checks); + mutableCloned.__checksPassiveEnabled = + !!upstream.checks?.passive && isNotEmpty(upstream.checks.passive); + } + + return cloned; +} + +/** + * Transform form format to internal format + * Key change: vars string is parsed back to array + */ +export function formToInternal(form: RoutePutType): RouteInternal { + // Deep clone to avoid mutation issues with frozen objects + const cloned = JSON.parse(JSON.stringify(form)) as RouteInternal; + + // Parse vars string back to array + if (cloned.vars && typeof cloned.vars === 'string') { + try { + cloned.vars = JSON.parse(cloned.vars as string); + } catch { + // Keep as-is if parse fails (will be caught by validation) + cloned.vars = []; + } + } + + return cloned; +} + +/** + * Transform internal format to JSON string for display + * Shows vars as natural array (not stringified) + */ +export function internalToJson(internal: RouteInternal): string { + // Deep clone to avoid mutation issues with frozen objects + const cloned = JSON.parse(JSON.stringify(internal)); + // Remove form-specific fields (__ prefixed) + rmDoubleUnderscoreKeys(cloned); + + return JSON.stringify(cloned, null, 2); +} + +/** + * Parse JSON string to internal format + */ +export function jsonToInternal(json: string): RouteInternal { + const parsed = JSON.parse(json); + + // Ensure vars is an array if present + if (parsed.vars && !Array.isArray(parsed.vars)) { + // If someone pastes stringified vars, try to parse it + if (typeof parsed.vars === 'string') { + try { + parsed.vars = JSON.parse(parsed.vars); + } catch { + parsed.vars = []; + } + } + } + + return parsed as RouteInternal; +} + +// ============================================ +// Composed convenience functions +// ============================================ + +/** + * API → Form (for loading data into form) + */ +export function apiToForm(api: APISIXType['Route']): RoutePutType { + return internalToForm(apiToInternal(api)); +} + +/** + * Form → API (for saving form data) + */ +export function formToApi(form: RoutePutType): APISIXType['Route'] { + return internalToApi(formToInternal(form)); +} + +/** + * API → JSON string (for JSON view display) + */ +export function apiToJson(api: APISIXType['Route']): string { + return internalToJson(apiToInternal(api)); +} + +/** + * JSON string → API (for saving from JSON view) + */ +export function jsonToApi(json: string): APISIXType['Route'] { + return internalToApi(jsonToInternal(json)); +} + +/** + * Form → JSON string (for switching to JSON view) + */ +export function formToJson(form: RoutePutType): string { + return internalToJson(formToInternal(form)); +} + +/** + * JSON string → Form (for switching to form view) + */ +export function jsonToForm(json: string): RoutePutType { + return internalToForm(jsonToInternal(json)); +} + +// ============================================ +// Validation helpers +// ============================================ + +/** + * Validate that a transformation round-trip preserves data + * Useful for debugging transformation issues + */ +export function validateRoundTrip( + original: APISIXType['Route'] +): { isValid: boolean; diff?: string } { + try { + const internal = apiToInternal(original); + const form = internalToForm(internal); + const backToInternal = formToInternal(form); + const backToApi = internalToApi(backToInternal); + + // Compare original and round-tripped (ignoring timestamps and empty fields) + const originalClean = produce(original, (draft) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mutableDraft = draft as any; + delete mutableDraft.create_time; + delete mutableDraft.update_time; + deepCleanEmptyKeys(draft); + }); + + const roundTrippedClean = produce(backToApi, (draft) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mutableDraft = draft as any; + delete mutableDraft.create_time; + delete mutableDraft.update_time; + deepCleanEmptyKeys(draft); + }); + + const isValid = + JSON.stringify(originalClean) === JSON.stringify(roundTrippedClean); + + if (!isValid) { + return { + isValid: false, + diff: `Original: ${JSON.stringify(originalClean)}\nRound-tripped: ${JSON.stringify(roundTrippedClean)}`, + }; + } + + return { isValid: true }; + } catch (error) { + return { + isValid: false, + diff: error instanceof Error ? error.message : 'Unknown error', + }; + } +}