mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(editor): provide yaml validation for docker compose in the portainer web editor [BE-11697] (#526)
This commit is contained in:
parent
0ebfe047d1
commit
81c5f4acc3
27 changed files with 2046 additions and 36 deletions
|
@ -11,6 +11,8 @@
|
|||
--bg-codemirror-gutters-color: var(--grey-17);
|
||||
--bg-codemirror-selected-color: var(--grey-22);
|
||||
--border-codemirror-cursor-color: var(--black-color);
|
||||
--bg-tooltip-color: var(--white-color);
|
||||
--text-tooltip-color: var(--black-color);
|
||||
}
|
||||
|
||||
:global([theme='dark']) .root {
|
||||
|
@ -24,6 +26,8 @@
|
|||
--bg-codemirror-gutters-color: var(--grey-3);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--bg-tooltip-color: var(--grey-3);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
}
|
||||
|
||||
:global([theme='highcontrast']) .root {
|
||||
|
@ -37,6 +41,8 @@
|
|||
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--bg-tooltip-color: var(--black-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-gutters) {
|
||||
|
@ -138,3 +144,21 @@
|
|||
.root :global(.cm-panel.cm-search label) {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
/* Tooltip styles for all themes */
|
||||
.root :global(.cm-tooltip) {
|
||||
@apply bg-white border border-solid border-gray-5 shadow-md text-xs rounded h-min;
|
||||
@apply th-dark:bg-gray-9 th-dark:border-gray-7 th-dark:text-white;
|
||||
@apply th-highcontrast:bg-black th-highcontrast:border-gray-7 th-highcontrast:text-white;
|
||||
}
|
||||
|
||||
/* Hide the completionInfo tooltip when it's empty */
|
||||
/* note: I only chose the complicated selector because the simple selector `.cm-tooltip.cm-completionInfo:empty` didn't work */
|
||||
.root :global(.cm-tooltip.cm-completionInfo:not(:has(*:not(:empty)))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Active line gutter styles for all themes */
|
||||
.root :global(.cm-activeLineGutter) {
|
||||
@apply bg-inherit;
|
||||
}
|
||||
|
|
115
app/react/components/CodeEditor.test.tsx
Normal file
115
app/react/components/CodeEditor.test.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
|
||||
vi.mock('yaml-schema', () => ({}));
|
||||
|
||||
const defaultProps = {
|
||||
id: 'test-editor',
|
||||
onChange: vi.fn(),
|
||||
value: '',
|
||||
'data-cy': 'test-editor',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render with basic props', () => {
|
||||
render(<CodeEditor {...defaultProps} />);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display placeholder when provided', async () => {
|
||||
const placeholder = 'Enter your code here';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} placeholder={placeholder} />
|
||||
);
|
||||
|
||||
const placeholderText = await findByText(placeholder);
|
||||
expect(placeholderText).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show copy button and copy content', async () => {
|
||||
const testValue = 'test content';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} value={testValue} />
|
||||
);
|
||||
|
||||
const mockClipboard = {
|
||||
writeText: vi.fn(),
|
||||
};
|
||||
Object.assign(navigator, {
|
||||
clipboard: mockClipboard,
|
||||
});
|
||||
|
||||
const copyButton = await findByText('Copy to clipboard');
|
||||
expect(copyButton).toBeVisible();
|
||||
|
||||
await userEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testValue);
|
||||
});
|
||||
|
||||
test('should handle read-only mode', async () => {
|
||||
const { findByRole } = render(<CodeEditor {...defaultProps} readonly />);
|
||||
const editor = await findByRole('textbox');
|
||||
// the editor should not editable
|
||||
await userEvent.type(editor, 'test');
|
||||
expect(editor).not.toHaveValue('test');
|
||||
});
|
||||
|
||||
test('should show version selector when versions are provided', async () => {
|
||||
const versions = [1, 2, 3];
|
||||
const onVersionChange = vi.fn();
|
||||
const { findByRole } = render(
|
||||
<CodeEditor
|
||||
{...defaultProps}
|
||||
versions={versions}
|
||||
onVersionChange={onVersionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const selector = await findByRole('combobox');
|
||||
expect(selector).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle YAML indentation correctly', async () => {
|
||||
const onChange = vi.fn();
|
||||
const yamlContent = 'services:';
|
||||
|
||||
const { findByRole } = render(
|
||||
<CodeEditor
|
||||
{...defaultProps}
|
||||
value={yamlContent}
|
||||
onChange={onChange}
|
||||
type="yaml"
|
||||
/>
|
||||
);
|
||||
|
||||
const editor = await findByRole('textbox');
|
||||
await userEvent.type(editor, '{enter}');
|
||||
await userEvent.keyboard('database:');
|
||||
await userEvent.keyboard('{enter}');
|
||||
await userEvent.keyboard('image: nginx');
|
||||
await userEvent.keyboard('{enter}');
|
||||
await userEvent.keyboard('name: database');
|
||||
|
||||
// Wait for the debounced onChange to be called
|
||||
setTimeout(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
'services:\n database:\n image: nginx\n name: database'
|
||||
);
|
||||
// debounce timeout is 300ms, so 500ms is enough
|
||||
}, 500);
|
||||
});
|
||||
|
||||
test('should apply custom height', async () => {
|
||||
const customHeight = '300px';
|
||||
const { findByRole } = render(
|
||||
<CodeEditor {...defaultProps} height={customHeight} />
|
||||
);
|
||||
|
||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
||||
expect(editor).toHaveStyle({ height: customHeight });
|
||||
});
|
|
@ -1,11 +1,24 @@
|
|||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||
import CodeMirror, {
|
||||
keymap,
|
||||
oneDarkHighlightStyle,
|
||||
} from '@uiw/react-codemirror';
|
||||
import {
|
||||
StreamLanguage,
|
||||
LanguageSupport,
|
||||
syntaxHighlighting,
|
||||
indentService,
|
||||
} from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
|
@ -28,6 +41,7 @@ interface Props extends AutomationTestingProps {
|
|||
height?: string;
|
||||
versions?: number[];
|
||||
onVersionChange?: (version: number) => void;
|
||||
schema?: JSONSchema7;
|
||||
}
|
||||
|
||||
const theme = createTheme({
|
||||
|
@ -57,18 +71,69 @@ const theme = createTheme({
|
|||
],
|
||||
});
|
||||
|
||||
const yamlLanguage = new LanguageSupport(StreamLanguage.define(yaml));
|
||||
// Custom indentation service for YAML
|
||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||
const prevLine = context.lineAt(pos, -1);
|
||||
|
||||
// Default to same as previous line
|
||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||
|
||||
// If previous line ends with a colon, increase indent
|
||||
if (/:\s*$/.test(prevLine.text)) {
|
||||
return prevIndent + 2; // Indent 2 spaces after a colon
|
||||
}
|
||||
|
||||
return prevIndent;
|
||||
});
|
||||
|
||||
// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
|
||||
const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
]);
|
||||
|
||||
const dockerFileLanguage = new LanguageSupport(
|
||||
StreamLanguage.define(dockerFile)
|
||||
);
|
||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||
|
||||
const docTypeExtensionMap: Record<Type, LanguageSupport> = {
|
||||
yaml: yamlLanguage,
|
||||
yaml: yamlLanguageLegacy,
|
||||
dockerfile: dockerFileLanguage,
|
||||
shell: shellLanguage,
|
||||
};
|
||||
|
||||
function schemaValidationExtensions(schema: JSONSchema7) {
|
||||
// skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
|
||||
// skip the completion extension in favor of custom completion
|
||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||
return [
|
||||
yaml,
|
||||
linter,
|
||||
autocompletion({
|
||||
icons: false,
|
||||
activateOnTypingDelay: 300,
|
||||
selectOnOpen: true,
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
(ctx) => {
|
||||
const getCompletions = yamlCompletion();
|
||||
const completions = getCompletions(ctx);
|
||||
if (Array.isArray(completions)) {
|
||||
return null;
|
||||
}
|
||||
return completions;
|
||||
},
|
||||
],
|
||||
}),
|
||||
stateExtensions,
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
];
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
onChange,
|
||||
|
@ -79,17 +144,22 @@ export function CodeEditor({
|
|||
onVersionChange,
|
||||
height = '500px',
|
||||
type,
|
||||
schema,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
const [isRollback, setIsRollback] = useState(false);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const extensions = [];
|
||||
if (type && docTypeExtensionMap[type]) {
|
||||
extensions.push(docTypeExtensionMap[type]);
|
||||
if (!type || !docTypeExtensionMap[type]) {
|
||||
return [];
|
||||
}
|
||||
return extensions;
|
||||
}, [type]);
|
||||
// YAML-specific schema validation
|
||||
if (schema && type === 'yaml') {
|
||||
return schemaValidationExtensions(schema);
|
||||
}
|
||||
// Default language support
|
||||
return [docTypeExtensionMap[type]];
|
||||
}, [type, schema]);
|
||||
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
|
@ -146,7 +216,7 @@ export function CodeEditor({
|
|||
height={height}
|
||||
basicSetup={{
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: false,
|
||||
autocompletion: !!schema,
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
/>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import {
|
||||
ReactNode,
|
||||
ComponentProps,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useTransitionHook } from '@uirouter/react';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
import { BROWSER_OS_PLATFORM } from '@/react/constants';
|
||||
|
||||
|
@ -63,6 +64,7 @@ interface Props extends CodeEditorProps {
|
|||
titleContent?: ReactNode;
|
||||
hideTitle?: boolean;
|
||||
error?: string;
|
||||
schema?: JSONSchema7;
|
||||
}
|
||||
|
||||
export function WebEditorForm({
|
||||
|
@ -71,6 +73,7 @@ export function WebEditorForm({
|
|||
hideTitle,
|
||||
children,
|
||||
error,
|
||||
schema,
|
||||
...props
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
|
@ -94,6 +97,8 @@ export function WebEditorForm({
|
|||
<div className="col-sm-12 col-lg-12">
|
||||
<CodeEditor
|
||||
id={id}
|
||||
type="yaml"
|
||||
schema={schema as JSONSchema7}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue