feat: yaml editor

This commit is contained in:
jialin
2025-10-13 14:40:26 +08:00
parent e863b84016
commit 54703c67c1
48 changed files with 2062 additions and 246 deletions
+148
View File
@@ -0,0 +1,148 @@
import IconFont from '@/components/icon-font';
import ModalFooter from '@/components/modal-footer';
import GSDrawer from '@/components/scroller-modal/gs-drawer';
import { PageActionType } from '@/config/types';
import { useIntl } from '@umijs/max';
import { Segmented, Tabs } from 'antd';
import React, { useRef, useState } from 'react';
import styled from 'styled-components';
import ColumnWrapper from '../../_components/column-wrapper';
import { FormData, ListItem } from '../config/types';
import BackendForm from '../forms';
import ImportYAML from './import-yaml';
const ModalFooterStyle = {
padding: '16px 24px',
display: 'flex',
justifyContent: 'flex-end'
};
const SegmentedInner = styled(Segmented)`
width: 100%;
border-radius: 0;
.ant-segmented-item {
flex: 1;
}
`;
const SegmentedHeader = styled.div`
margin-top: 16px;
padding: 0 24px;
`;
interface AddModalProps {
action: PageActionType;
currentData?: ListItem; // Used when action is EDIT
onClose: () => void;
onSubmit: (values: FormData) => void;
open: boolean;
title?: string;
}
const AddModal: React.FC<AddModalProps> = (props) => {
const { action, currentData, onClose, onSubmit, open, title } = props;
const formRef = useRef<any>(null);
const editorRef = useRef<any>(null);
const intl = useIntl();
const [activeKey, setActiveKey] = useState<string>('form');
const onOk = () => {
if (activeKey === 'yaml') {
const content = editorRef.current?.getContent();
const valid = editorRef.current?.validate();
if (valid) {
onSubmit({ content: content });
}
} else {
formRef.current?.submit();
}
};
const onFinish = (values: FormData) => {
onSubmit(values);
};
return (
<GSDrawer
title={title}
open={open}
onClose={onClose}
destroyOnHidden={true}
closeIcon={false}
maskClosable={false}
keyboard={false}
styles={{
body: {
padding: '0 0 16px',
overflowX: 'hidden'
},
content: {
borderRadius: '6px 0 0 6px'
}
}}
width={600}
footer={false}
>
<SegmentedHeader>
<SegmentedInner
shape="round"
onChange={(value) => setActiveKey(value as string)}
options={[
{
label: 'Form',
value: 'form',
icon: <IconFont type="icon-edit-content" />
},
{
label: 'YAML',
value: 'yaml',
icon: <IconFont type="icon-code_block" />
}
]}
size="middle"
style={{ width: '100%' }}
/>
</SegmentedHeader>
<ColumnWrapper
style={{ flex: 1 }}
maxHeight={'calc(100vh - 123px)'}
footer={
<ModalFooter
onCancel={onClose}
onOk={onOk}
style={ModalFooterStyle}
></ModalFooter>
}
>
<Tabs
renderTabBar={() => <></>}
activeKey={activeKey}
defaultActiveKey={activeKey}
items={[
{
key: 'form',
label: 'Form',
children: (
<BackendForm
onFinish={onFinish}
action={action}
currentData={currentData}
ref={formRef}
/>
)
},
{
key: 'yaml',
label: 'YAML',
children: (
<ImportYAML action={action} ref={editorRef}></ImportYAML>
)
}
]}
></Tabs>
</ColumnWrapper>
</GSDrawer>
);
};
export default AddModal;
+23 -7
View File
@@ -1,6 +1,7 @@
import DropDownActions from '@/components/drop-down-actions';
import IconFont from '@/components/icon-font';
import Card from '@/components/templates/card';
import { DockerOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import styled from 'styled-components';
import { backendActions } from '../config';
@@ -22,8 +23,17 @@ const Content = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 12x;
gap: 16px;
margin-top: 8px;
gap: 12px;
color: var(--ant-color-text-secondary);
.description {
margin-block: 12px;
color: var(--ant-color-text-tertiary);
}
.text {
margin-left: 4px;
color: var(--ant-color-text-secondary);
}
.title {
color: var(--ant-color-text);
font-weight: 500;
@@ -47,12 +57,13 @@ const BackendCard: React.FC<BackendCardProps> = ({
onClick={() => onClick(data)}
clickable={true}
disabled={false}
height={205}
height={'auto'}
ghost
header={
<Header>
<div className="title">
<img src={data.icon} alt={data.backend_name} height={20} />
<span>{data.backend_name}</span>
</div>
<span>
<DropDownActions
@@ -72,11 +83,16 @@ const BackendCard: React.FC<BackendCardProps> = ({
}
>
<Content>
<div className="title">{data.backend_name}</div>
<div>This is a description</div>
<div>{data.default_version}</div>
<div className="description">This is a description</div>
<div>
{data.version_configs?.[data.default_version]?.image_name}
<IconFont type="icon-version" style={{ marginRight: 4 }} /> Default
version: <span className="text">{data.default_version}</span>
</div>
<div>
<DockerOutlined style={{ marginRight: 4 }} /> Default Image:{' '}
<span className="text">
{data.version_configs?.[data.default_version]?.image_name}
</span>
</div>
</Content>
</Card>
@@ -0,0 +1,145 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useUserSettings from '@/hooks/use-user-settings';
import { ImportOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, message, Typography, Upload } from 'antd';
import { RcFile } from 'antd/lib/upload';
import React, {
forwardRef,
useImperativeHandle,
useRef,
useState
} from 'react';
import styled from 'styled-components';
import { yamlTemplate } from '../config';
import YamlEditor from './yaml-editor';
const { Text } = Typography;
const ErrorText = styled(Text)`
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 6px;
background-color: var(--ant-color-bg-elevated);
border-radius: 0 0 var(--ant-border-radius) var(--ant-border-radius);
`;
const Header = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding-inline: 10px;
border-bottom: 1px solid var(--ant-color-border);
background-color: var(--ant-color-fill-tertiary);
`;
const Container = styled.div`
position: relative;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius);
.monaco-editor .scroll-decoration {
box-shadow: none;
}
`;
interface ImportYAMLProps {
ref?: any;
action: PageActionType;
content?: string;
onSubmit?: (content: string) => void;
}
const ImportYAML: React.FC<ImportYAMLProps> = forwardRef(
({ action, content = '' }, ref) => {
const intl = useIntl();
const { userSettings } = useUserSettings();
const editorRef = useRef<any>(null);
const [fileContent, setFileContent] = useState<string>(
action === PageAction.CREATE ? yamlTemplate : content
);
const [error, setError] = useState<string>('');
const beforeUpload = (file: RcFile) => {
const isYaml =
file.type === 'application/x-yaml' ||
file.type === 'text/yaml' ||
file.name.endsWith('.yaml') ||
file.name.endsWith('.yml');
if (!isYaml) {
message.error('You can only upload YAML file!');
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result;
if (typeof content === 'string') {
setFileContent(content);
} else {
message.error('Failed to read file content!');
}
};
reader.readAsText(file);
// Prevent upload
return false;
};
const validate = () => {
const markers = editorRef.current?.validate();
if (markers && markers.length > 0) {
setError(markers[0].message);
}
return markers.length === 0;
};
const renderHeader = () => {
return (
<Header>
<span className="title">YAML Editor</span>
<Upload
name="file"
multiple={false}
beforeUpload={beforeUpload}
showUploadList={false}
accept=".yaml,.yml,text/yaml,application/x-yaml"
>
<Button icon={<ImportOutlined />} type="text" size="small">
Import
</Button>
</Upload>
</Header>
);
};
useImperativeHandle(ref, () => ({
getContent: () => {
return editorRef.current?.getValue();
},
setContent: (val: string) => {
editorRef.current?.setValue?.(val);
},
validate() {
return validate();
}
}));
return (
<Container>
<YamlEditor
ref={editorRef}
lang="yaml"
theme={userSettings?.isDarkTheme ? 'vs-dark' : 'vs-light'}
value={fileContent}
height={'calc(100vh - 255px)'}
header={renderHeader()}
></YamlEditor>
{error && <ErrorText type="danger">{error}</ErrorText>}
</Container>
);
}
);
export default ImportYAML;
@@ -0,0 +1,167 @@
import EditorWrap from '@/components/editor-wrap';
import { LoadingOutlined } from '@ant-design/icons';
import Editor, { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import { configureMonacoYaml } from 'monaco-yaml';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef
} from 'react';
import schema from '../config/schema.json';
loader.config({ monaco });
interface ViewerProps {
ref?: any;
lang: string;
defaultLang?: string;
config?: any;
value: string;
height?: string | number;
theme?: string;
header?: React.ReactNode;
placeholder?: string;
variant?: 'bordered' | 'borderless';
}
const path = 'inmemory://model/config.yaml';
const ViewerEditor: React.FC<ViewerProps> = forwardRef((props, ref) => {
const {
lang,
value,
config,
defaultLang,
height = 380,
theme = 'vs-dark',
header,
variant = 'borderless',
placeholder
} = props;
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const monacoYamlRef = useRef<any>(null);
const handleBeforeMount = (monaco: any) => {
const yamlUri = monaco.Uri.parse(path);
configureMonacoYaml(monaco, {
schemas: [
{
uri: 'http://example.com/schema-name.json',
fileMatch: [yamlUri.toString()],
schema
}
]
});
};
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor;
monacoRef.current = monaco;
const uri = monaco.Uri.parse(path);
// monaco.editor.onDidChangeMarkers((uris: string[]) => {
// if (!uris.some((u) => u.toString() === uri.toString())) return;
// const model = monaco.editor.getModel(uri);
// if (!model) return;
// console.log('Markers changed:', uris);
// const markers = monaco.editor.getModelMarkers({ resource: uri });
// const adjusted = markers.map((m: any) => {
// if (m._adjusted) return m;
// return {
// ...m,
// _adjusted: true,
// severity: monaco.MarkerSeverity.ErrorText
// };
// });
// monaco.editor.setModelMarkers(
// monaco.editor.getModel(uri),
// 'yaml',
// adjusted
// );
// });
};
const formatCode = () => {
if (editorRef.current) {
setTimeout(() => {
editorRef.current
?.getAction?.('editor.action.formatDocument')
?.run()
.then(() => {
console.log('format success');
});
}, 100);
}
};
const getMarkers = () => {
const uri = editorRef.current?.getModel()?.uri;
const markers = monacoRef.current?.editor.getModelMarkers({
resource: uri
});
console.log('Validation markers:', uri, markers);
return markers;
};
useImperativeHandle(ref, () => ({
format: () => {
formatCode();
},
getValue: () => {
return editorRef.current?.getValue?.();
},
setValue: (val: string) => {
editorRef.current?.setValue?.(val);
},
dispose: () => {
editorRef.current?.dispose?.();
monacoYamlRef.current?.dispose?.();
},
validate() {
return getMarkers();
},
editor: editorRef.current
}));
useEffect(() => {
formatCode();
}, [value]);
return (
<EditorWrap header={header} variant={variant}>
<Editor
path={path}
defaultPath={path}
height={height}
theme={theme}
className="monaco-editor"
defaultLanguage={'yaml'}
language={'yaml'}
value={value}
options={{
minimap: { enabled: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
fontSize: 14,
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6
},
placeholder: placeholder
}}
loading={<LoadingOutlined style={{ fontSize: 24 }}></LoadingOutlined>}
beforeMount={handleBeforeMount}
onMount={handleEditorDidMount}
/>
</EditorWrap>
);
});
export default ViewerEditor;