1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +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}
/>

View file

@ -1,3 +1,5 @@
import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
import { InlineLoader } from '@@/InlineLoader';
import { WebEditorForm } from '@@/WebEditorForm';
@ -14,7 +16,9 @@ export function DockerContentField({
readonly?: boolean;
isLoading?: boolean;
}) {
if (isLoading) {
const dockerComposeSchemaQuery = useDockerComposeSchema();
if (isLoading || dockerComposeSchemaQuery.isInitialLoading) {
return <InlineLoader>Loading stack content...</InlineLoader>;
}
@ -27,6 +31,7 @@ export function DockerContentField({
placeholder="Define or paste the content of your docker compose file here"
error={error}
readonly={readonly}
schema={dockerComposeSchemaQuery.data}
data-cy="stack-creation-editor"
>
You can get more information about Compose file format in the{' '}

View file

@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { mockCodeMirror, renderCreateForm } from './utils.test';
import { renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;

View file

@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { mockCodeMirror, renderCreateForm } from './utils.test';
import { renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;

View file

@ -6,6 +6,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { http, server } from '@/setup-tests/server';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { CreateForm } from '../CreateForm';
@ -211,13 +212,6 @@ test('The form should render', async () => {
});
});
export function mockCodeMirror() {
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div />,
}));
}
export function renderCreateForm() {
// user declaration needs to go at the start for user id related requests (e.g. git credentials)
const user = new UserViewModel({ Username: 'user' });

View file

@ -1,5 +1,7 @@
import { useFormikContext } from 'formik';
import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
import { TextTip } from '@@/Tip/TextTip';
import { WebEditorForm } from '@@/WebEditorForm';
@ -19,6 +21,7 @@ export function ComposeForm({
versionOptions: number[] | undefined;
}) {
const { errors, values } = useFormikContext<FormValues>();
const { data: dockerComposeSchema } = useDockerComposeSchema();
return (
<>
@ -61,6 +64,7 @@ export function ComposeForm({
data-cy="compose-editor"
value={values.content}
type="yaml"
schema={dockerComposeSchema}
id="compose-editor"
placeholder="Define or paste the content of your docker compose file here"
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
import { JSONSchema7 } from 'json-schema';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { dockerComposeSchema } from './docker-compose-schema';
const COMPOSE_SCHEMA_URL =
'https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json';
export function useDockerComposeSchema() {
return useQuery<JSONSchema7>(
['docker-compose-schema'],
getDockerComposeSchema,
{
staleTime: 24 * 60 * 60 * 1000, // 24 hours
cacheTime: 30 * 24 * 60 * 60 * 1000, // 30 days
retry: 1,
refetchOnWindowFocus: false,
// Start with local schema while fetching
initialData: dockerComposeSchema as JSONSchema7,
}
);
}
export async function getDockerComposeSchema() {
try {
const response = await axios.get<JSONSchema7>(COMPOSE_SCHEMA_URL);
// just in case a non-object is returned from a proxy
if (typeof response.data !== 'object') {
return dockerComposeSchema as JSONSchema7;
}
return response.data;
} catch (error) {
// Return the local schema as fallback for airgapped environments
return dockerComposeSchema as JSONSchema7;
}
}