Skip to content

feat: protos page, FileUploadTextarea component #3001

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"antd": "^5.24.8",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"fast-clean": "^1.4.0",
"i18next": "^25.0.1",
"immer": "^10.1.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions src/components/form-slice/FormDisplayDate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { InputWrapper, Text, type InputWrapperProps } from '@mantine/core';
import dayjs from 'dayjs';

type FormDisplayDateProps = InputWrapperProps & {
date: dayjs.ConfigType;
};
export const FormDisplayDate = (props: FormDisplayDateProps) => {
const { date, ...rest } = props;
return (
<InputWrapper {...rest}>
<Text size="sm" c="gray.6">
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</Text>
</InputWrapper>
);
};
18 changes: 3 additions & 15 deletions src/components/form-slice/FormPartBasic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,13 @@ import { FormItemTextarea } from '../form/Textarea';
import { useFormContext } from 'react-hook-form';
import type { A6Type } from '@/types/schema/apisix';

export type FormPartBasicProps = Omit<FormSectionProps, 'form'> & {
showId?: boolean;
};
export type FormPartBasicProps = Omit<FormSectionProps, 'form'>;

export const FormPartBasic = (props: FormPartBasicProps) => {
const { showId, ...restProps } = props;
const { control } = useFormContext<A6Type['Basic'] & { id?: string }>();
const { control } = useFormContext<A6Type['Basic']>();
const { t } = useTranslation();
return (
<FormSection legend={t('form.basic.title')} {...restProps}>
{showId && (
<FormItemTextInput
name="id"
label="ID"
control={control}
readOnly
disabled
/>
)}
<FormSection legend={t('form.basic.title')} {...props}>
<FormItemTextInput
name="name"
label={t('form.basic.name')}
Expand Down
25 changes: 25 additions & 0 deletions src/components/form-slice/FormPartProto.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { A6Type } from '@/types/schema/apisix';
import { useFormContext, type FieldValues } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
FileUploadTextarea,
type FileUploadTextareaProps,
} from '../form/FileUploadTextarea';

export const FormPartProto = <T extends FieldValues>(
props: Pick<FileUploadTextareaProps<T>, 'allowUpload'>
) => {
const { t } = useTranslation();
const form = useFormContext<A6Type['ProtoPost']>();
return (
<FileUploadTextarea
name="content"
label={t('protos.form.content')}
placeholder={t('protos.form.contentPlaceholder')}
control={form.control}
minRows={10}
acceptFileTypes=".proto,.pb"
{...props}
/>
);
};
4 changes: 2 additions & 2 deletions src/components/form-slice/FormPartUpstream/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,11 @@ export const FormSectionKeepAlive = () => {
</FormSection>
);
};
export const FormPartUpstream = ({ showId }: { showId?: boolean }) => {
export const FormPartUpstream = () => {
const { t } = useTranslation();
return (
<>
<FormPartBasic showId={showId} />
<FormPartBasic />
<FormSection legend={t('form.upstream.findUpstreamFrom')}>
<FormSection legend={t('form.upstream.nodes.title')}>
<FormItemNodes
Expand Down
28 changes: 28 additions & 0 deletions src/components/form-slice/FormSectionInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useFormContext, useWatch } from 'react-hook-form';
import type { A6Type } from '@/types/schema/apisix';
import { FormItemTextInput } from '../form/TextInput';
import { FormSection } from './FormSection';
import { useTranslation } from 'react-i18next';
import { FormDisplayDate } from './FormDisplayDate';
import { Divider } from '@mantine/core';

export const FormSectionInfo = () => {
const { control } = useFormContext<A6Type['Info']>();
const { t } = useTranslation();
const createTime = useWatch({ control, name: 'create_time' });
const updateTime = useWatch({ control, name: 'update_time' });
return (
<FormSection legend={t('form.info.title')}>
<FormItemTextInput
control={control}
name="id"
label="ID"
readOnly
disabled
/>
<Divider my="lg" />
<FormDisplayDate date={createTime} label={t('form.info.create_time')} />
<FormDisplayDate date={updateTime} label={t('form.info.update_time')} />
</FormSection>
);
};
114 changes: 114 additions & 0 deletions src/components/form/FileUploadTextarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
Textarea as MTextarea,
type TextareaProps as MTextareaProps,
Box,
Group,
Button,
Input,
} from '@mantine/core';
import {
useController,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { genControllerProps } from './util';
import IconUpload from '~icons/material-symbols/upload';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

export type FileUploadTextareaProps<T extends FieldValues> = UseControllerProps<T> &
MTextareaProps & {
acceptFileTypes?: string;
uploadButtonText?: string;
maxFileSize?: number;
onFileLoaded?: (content: string, fileName: string) => void;
allowUpload?: boolean;
};

export const FileUploadTextarea = <T extends FieldValues>(
props: FileUploadTextareaProps<T>
) => {
const { allowUpload = true } = props;
const { controllerProps, restProps } = genControllerProps(props, '');
const {
field: { value, onChange: fOnChange, ...restField },
fieldState,
} = useController<T>(controllerProps);
const { t } = useTranslation();

const [fileError, setFileError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const {
acceptFileTypes = '.txt,.json,.yaml,.yml',
uploadButtonText = '',
maxFileSize = 10 * 1024 * 1024,
onFileLoaded,
onChange,
...textareaProps
} = restProps;

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

if (file.size > maxFileSize) {
const size = Math.round(maxFileSize / 1024 / 1024);
setFileError(`${t('form.upload.fileOverSize')} ${size}MB`);
return;
}

setFileError(null);
if (fileInputRef.current) fileInputRef.current.value = '';

const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
fOnChange(content);
onFileLoaded?.(content, file.name);
};

reader.onerror = (e) => {
setFileError(`${t('form.upload.readError')} ${e.target?.error}`);
};

reader.readAsText(file);
};

return (
<Box>
<MTextarea
value={value}
onChange={(e) => {
fOnChange(e);
onChange?.(e);
}}
resize="vertical"
autosize={restField.disabled}
{...restField}
{...textareaProps}
/>
{allowUpload && (
<Group mb="xs" mt={4}>
<Button
leftSection={<IconUpload />}
size="compact-xs"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={restField.disabled}
>
{uploadButtonText || t('form.btn.upload')}
</Button>
<input
type="file"
accept={acceptFileTypes}
onChange={handleFileChange}
style={{ display: 'none' }}
ref={fileInputRef}
/>
</Group>
)}
<Input.Error>{fieldState.error?.message || fileError}</Input.Error>
</Box>
);
};
1 change: 1 addition & 0 deletions src/config/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const LOCAL_STORAGE_ADMIN_KEY = 'apisix-admin-key';
export const API_PREFIX = '/apisix/admin';
export const API_ROUTES = '/routes';
export const API_UPSTREAMS = '/upstreams';
export const API_PROTOS = '/protos';

export const APPSHELL_HEADER_HEIGHT = 60;
export const APPSHELL_NAVBAR_WIDTH = 250;
4 changes: 4 additions & 0 deletions src/config/navRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ export const navRoutes: NavRoute[] = [
to: '/secret',
label: 'secret',
},
{
to: '/protos',
label: 'protos',
},
];
14 changes: 13 additions & 1 deletion src/config/req.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@ type A6RespErr = {
};

req.interceptors.response.use(
(res) => res,
(res) => {
// it's a apisix design
// when list is empty, it will be a object
// but we need a array
if (
res.data?.list &&
!Array.isArray(res.data.list) &&
Object.keys(res.data.list).length === 0
) {
res.data.list = [];
}
return res;
},
(err) => {
if (err.response) {
const d = err.response.data as A6RespErr;
Expand Down
27 changes: 26 additions & 1 deletion src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@
"add": "Add",
"delete": "Delete",
"edit": "Edit",
"save": "Save"
"save": "Save",
"upload": "Upload"
},
"disabled": "Disabled, click switch to enable",
"info": {
"create_time": "Created At",
"title": "Information",
"update_time": "Updated At"
},
"upload": {
"fileOverSize": "file size is too large",
"readError": "file read error:"
},
"upstream": {
"checks": {
"active": {
Expand Down Expand Up @@ -139,6 +149,7 @@
"navbar": {
"consumer": "Consumer",
"pluginGlobalRules": "Plugin Global Rules",
"protos": "Protos",
"route": "Route",
"secret": "Secret",
"service": "Service",
Expand All @@ -147,6 +158,20 @@
},
"noData": "No Data",
"or": "OR",
"protos": {
"add": {
"success": "Add Proto Successfully",
"title": "Add Proto"
},
"detail": {
"title": "Proto Detail"
},
"form": {
"content": "Content",
"contentPlaceholder": "Paste or upload .proto, .pb file"
},
"title": "Protos"
},
"route": {
"add": {
"form": {
Expand Down
1 change: 0 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { queryClient, router } from './config/global';

import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-react-table/styles.css';
import './styles/global.css';

const theme = createTheme({});
Expand Down
Loading