1
0
Fork 0
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:
Ali 2025-03-27 17:11:55 +13:00 committed by GitHub
parent 0ebfe047d1
commit 81c5f4acc3
27 changed files with 2046 additions and 36 deletions

View file

@ -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;
}

View 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 });
});

View file

@ -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}
/>

View file

@ -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}
/>